From 6ead99e15c56ed91157e55b815d568dc27a11372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 28 Oct 2025 16:58:10 +0900 Subject: [PATCH 001/164] =?UTF-8?q?round1:=201=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C,=20=EA=B3=BC=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/week01.md | 289 ++++++++++++++++++++++++++++++++++++++++++ docs/week01_quests.md | 131 +++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 docs/week01.md create mode 100644 docs/week01_quests.md diff --git a/docs/week01.md b/docs/week01.md new file mode 100644 index 000000000..decb1ea16 --- /dev/null +++ b/docs/week01.md @@ -0,0 +1,289 @@ +# ๐Ÿงญ ๋ฃจํ”„ํŒฉ BE L2 - Round 1 + +> ๋‹จ์ˆœํžˆ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์˜๋„๋ฅผ ์„ค๊ณ„ํ•œ๋‹ค. +> + + + +- ๊ธฐ๋Šฅ ๊ตฌํ˜„๋ณด๋‹ค ๋จผ์ € ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ณธ๋‹ค. +- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ž€ ๋ฌด์—‡์ธ์ง€ ์ฒด๊ฐํ•ด๋ณธ๋‹ค. +- ์œ ์ € ๋“ฑ๋ก/์กฐํšŒ, ํฌ์ธํŠธ ์ถฉ์ „ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธ ์ฃผ๋„๋กœ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค. + + + +- ๋‹จ์œ„ ํ…Œ์ŠคํŠธ vs ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ +- ํ…Œ์ŠคํŠธ ๋”๋ธ”(Mock, Stub, Fake ๋“ฑ) +- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ์ฝ”๋“œ ๊ตฌ์กฐ +- ํ…Œ์ŠคํŠธ ์ฃผ๋„ ๊ฐœ๋ฐœ (TDD) + + + +## ๐Ÿงช ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ + +> ํ…Œ์ŠคํŠธ๋Š” ์•„๋ž˜์ฒ˜๋Ÿผ **๋ฒ”์œ„์— ๋”ฐ๋ผ ์—ญํ• ๊ณผ ์ฑ…์ž„์ด ๋‚˜๋‰˜๋ฉฐ**, +ํ•˜๋‹จ์ผ์ˆ˜๋ก ๋น ๋ฅด๊ณ  ๋งŽ์ด, ์ƒ๋‹จ์ผ์ˆ˜๋ก ๋А๋ฆฌ์ง€๋งŒ ์‹ ์ค‘ํ•˜๊ฒŒ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค. +> + +![Untitled](attachment:54f631d6-538a-44fa-8358-026c73efed68:Untitled.png) + +### ๐Ÿงฑ 1. **๋‹จ์œ„ ํ…Œ์ŠคํŠธ (Unit Test)** + +- **๋Œ€์ƒ:** ๋„๋ฉ”์ธ ๋ชจ๋ธ (Entity, VO, Domain Service) +- **๋ชฉ์ :** ์ˆœ์ˆ˜ ๋กœ์ง์˜ ์ •ํ•ฉ์„ฑ๊ณผ ๊ทœ์น™ ๊ฒ€์ฆ +- **ํ™˜๊ฒฝ:** Spring ์—†์ด ์ˆœ์ˆ˜ JVM์—์„œ ์‹คํ–‰ (JVM ๋‹จ์œ„ ํ…Œ์ŠคํŠธ) / **ํ…Œ์ŠคํŠธ ๋Œ€์—ญ** ์„ ํ™œ์šฉํ•ด ๋ชจ๋“  ์˜์กด์„ฑ์„ ๋Œ€์ฒด +- **๊ธฐ์ˆ :** JUnit5, Kotest, AssertJ ๋“ฑ + +> ๐Ÿ’ฌ ์˜ˆ: ํฌ์ธํŠธ ์ถฉ์ „ ์‹œ ์ตœ๋Œ€ ํ•œ๋„ ์ดˆ๊ณผ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ํ…Œ์ŠคํŠธ +> + +### ๐Ÿ” 2. **ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ (Integration Test)** + +- **๋Œ€์ƒ:** ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ Service, Facade ๋“ฑ ๊ณ„์ธต ๋กœ์ง +- **๋ชฉ์ :** ์—ฌ๋Ÿฌ ์ปดํฌ๋„ŒํŠธ(Repo, Domain, ์™ธ๋ถ€ API Stub)๊ฐ€ ์—ฐ๊ฒฐ๋œ ์ƒํƒœ์—์„œ **๋น„์ฆˆ๋‹ˆ์Šค ํ๋ฆ„ ์ „์ฒด๋ฅผ ๊ฒ€์ฆ** +- **ํ™˜๊ฒฝ:** `@SpringBootTest`, ์‹ค์ œ Bean ๊ตฌ์„ฑ, Test DB +- **๊ธฐ์ˆ :** SpringBootTest + H2 + TestContainers ๋“ฑ + +> ๐Ÿ’ฌ ์˜ˆ: ์‹ค์ œ ํฌ์ธํŠธ๊ฐ€ ์ถฉ์ „๋˜๊ณ , DB์— ๋ฐ˜์˜๋˜๋ฉฐ, ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœํ–‰๋˜๋Š” ์ „ ๊ณผ์ •์„ ๊ฒ€์ฆ +> + +### ๐ŸŒ 3. **E2E ํ…Œ์ŠคํŠธ (End-to-End Test)** + +- **๋Œ€์ƒ:** ์ „์ฒด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ (Controller โ†’ Service โ†’ DB) +- **๋ชฉ์ :** ์‹ค์ œ HTTP ์š”์ฒญ ๋‹จ์œ„ ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ +- **ํ™˜๊ฒฝ:** `MockMvc` ๋˜๋Š” `TestRestTemplate`์„ ํ†ตํ•ด ์‹ค์ œ API ์š”์ฒญ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ +- **๊ธฐ์ˆ :** SpringBootTest + `@AutoConfigureMockMvc`, `WebTestClient` ๋“ฑ + +> ๐Ÿ’ฌ ์˜ˆ: ์‚ฌ์šฉ์ž๊ฐ€ ํšŒ์›๊ฐ€์ž… โ†’ ํฌ์ธํŠธ ์ถฉ์ „ โ†’ ์ฃผ๋ฌธ ํ๋ฆ„์„ HTTP ์š”์ฒญ์œผ๋กœ ์ˆ˜ํ–‰ํ–ˆ์„ ๋•Œ์˜ ๊ฒฐ๊ณผ ํ™•์ธ +> + +--- + +## ๐Ÿ”ง ํ…Œ์ŠคํŠธ ๋”๋ธ”(Test Doubles) + +> ํ…Œ์ŠคํŠธ ๋Œ€์ƒ์ด ์˜์กดํ•˜๋Š” ์™ธ๋ถ€ ๊ฐ์ฒด์˜ ๋™์ž‘์„ **๋น ๋ฅด๊ณ  ์•ˆ์ „ํ•˜๊ฒŒ ํ‰๋‚ด ๋‚ด๋Š” ๋Œ€์—ญ ๊ฐ์ฒด** ์ž…๋‹ˆ๋‹ค. +๋А๋ฆฌ๊ณ  ๋ถˆ์•ˆ์ •ํ•œ ์‹ค์ œ ๊ตฌํ˜„ ๋Œ€์‹ , ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์— ๋งž๋Š” **โ€˜์กฐ์šฉํ•œ ๋Œ€์—ญโ€™** ์„ ์„ธ์›Œ์ค๋‹ˆ๋‹ค. +> + +### ๐Ÿงฉ ํ…Œ์ŠคํŠธ ๋”๋ธ”์€ ์—ญํ• , `mock()`๊ณผ `spy()`๋Š” ๋„๊ตฌ + +- `Stub`, `Mock`, `Spy`, `Fake` ๋Š” **ํ…Œ์ŠคํŠธ ๋ชฉ์  (์—ญํ• )** +- `mock()`, `spy()`๋Š” **๊ฐ์ฒด ์ƒ์„ฑ ๋ฐฉ์‹ (๋„๊ตฌ)** + +e.g. + +```kotlin +val repo = mock() // ๋„๊ตฌ: mock() +whenever(repo.findById(1L)).thenReturn(User(...)) // ์—ญํ• : Stub +verify(repo).findById(1L) // ์—ญํ• : Mock +``` + +> โœ… mock ๊ฐ์ฒด์— stub + mock ์—ญํ• ์„ ๋™์‹œ์— ๋ถ€์—ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +> + +### ๐Ÿ“š TestDouble ์—ญํ• ๋ณ„ ์ •๋ฆฌ + +| ์—ญํ•  | ๋ชฉ์  | ์‚ฌ์šฉ ๋ฐฉ์‹ | ์˜ˆ์‹œ | +| --- | --- | --- | --- | +| **Dummy** | ์ž๋ฆฌ๋งŒ ์ฑ„์›€ (์‚ฌ์šฉ๋˜์ง€ ์•Š์Œ) | ์ƒ์„ฑ์ž ๋“ฑ์—์„œ ์ „๋‹ฌ | `User(null, null)` | +| **Stub** | ๊ณ ์ •๋œ ์‘๋‹ต ์ œ๊ณต (์ƒํƒœ ๊ธฐ๋ฐ˜) | `when().thenReturn()` | `repo.find()` โ†’ ํ•ญ์ƒ ํŠน์ • ์œ ์ € ๋ฐ˜ํ™˜ | +| **Mock** | ํ˜ธ์ถœ ์—ฌ๋ถ€/ํšŸ์ˆ˜ ๊ฒ€์ฆ (ํ–‰์œ„ ๊ธฐ๋ฐ˜) | `verify(...)` | ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋˜์—ˆ๋Š”์ง€ ๊ฒ€์ฆ | +| **Spy** | ์ง„์งœ ๊ฐ์ฒด ๊ฐ์‹ธ๊ธฐ + ์ผ๋ถ€ ์กฐ์ž‘ | `spy()` + `doReturn()` | ์ง„์งœ ์„œ๋น„์Šค ๊ฐ์‹ธ๊ณ  ์ผ๋ถ€๋งŒ stub | +| **Fake** | ์‹ค์ œ์ฒ˜๋Ÿผ ๋™์ž‘ํ•˜๋Š” ๊ฐ€์งœ ๊ตฌํ˜„์ฒด | ์ง์ ‘ ํด๋ž˜์Šค ๊ตฌํ˜„ | **InMemoryUserRepository** | + +### ๐Ÿ” TestDouble ์‹ค์ „ ์˜ˆ์ œ + +### ๐Ÿ“ฆ Stub ์˜ˆ์ œ + +```kotlin +val userRepo = mock() +whenever(userRepo.findById(1L)).thenReturn(User("alen")) +``` + +- ํ๋ฆ„๋งŒ ํ†ต์ œํ•˜๊ณ  ์‹ถ์€ ๊ฒฝ์šฐ +- โ€œ์ด๋ ‡๊ฒŒ ํ˜ธ์ถœํ•˜๋ฉด, ์ด๋ ‡๊ฒŒ ์‘๋‹ตํ•ด์ค˜โ€ + +### ๐Ÿ“ฌ Mock ์˜ˆ์ œ + +```kotlin +val speaker = mock() +speaker.say("hello") +verify(speaker, times(1)).say("hello") +``` + +- ํ˜ธ์ถœ ์—ฌ๋ถ€๊ฐ€ ๊ฒ€์ฆ ๋Œ€์ƒ +- โ€œ๋„ˆ ์ด๋ ‡๊ฒŒ ๋™์ž‘ํ–ˆ๋‹ˆ?โ€ + +### ๐Ÿ•ต๏ธ Spy ์˜ˆ์ œ + +```kotlin +val friend = Friend() +val spyFriend = spy(friend) +spyFriend.hangout() +verify(spyFriend).hangout() +``` + +- ์ง„์งœ ๊ฐ์ฒด์ฒ˜๋Ÿผ ๋™์ž‘ํ•˜๋ฉด์„œ ์ผ๋ถ€๋งŒ ์กฐ์ž‘ +- "๋กœ์ง์€ ๊ทธ๋Œ€๋กœ ์“ฐ๊ณ , ํŠน์ • ๋™์ž‘๋งŒ ๋ฎ์–ด์”Œ์šฐ๊ณ  / ๊ฒ€์ฆํ•˜๊ณ  ์‹ถ๋‹ค" + +### ๐Ÿงช Fake ์˜ˆ์ œ + +```kotlin +class InMemoryUserRepository : UserRepository { + private val data = mutableMapOf() + + override fun save(user: User) { data[user.id] = user } + override fun findById(id: Long): User? = data[user.id] +} +``` + +- ์‹ค์ œ DB ์—†์ด ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ์ €์žฅ์†Œ ๊ตฌํ˜„ +- "์™„์ „ํžˆ ๋…๋ฆฝ์ ์ธ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์ด ํ•„์š”ํ•  ๋•Œโ€ + +--- + +## ๐Ÿงฑ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ + +> **๊ฒ€์ฆํ•˜๊ณ  ์‹ถ์€ ๋กœ์ง์„, ์™ธ๋ถ€ ์˜์กด์„ฑ๊ณผ ๊ฒฉ๋ฆฌ๋œ ์ƒํƒœ์—์„œ ๋‹จ๋…์œผ๋กœ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋Š” ๊ตฌ์กฐ**์ž…๋‹ˆ๋‹ค. +> +> +> ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ž€, ๊ฒ€์ฆํ•˜๊ณ  ์‹ถ์€ ์ฝ”๋“œ๋งŒ ์ •ํ™•ํžˆ ๊บผ๋‚ด์„œ **์กฐ์šฉํ•˜๊ณ  ๋‹จ๋‹จํ•˜๊ฒŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ตฌ์กฐ**๋‹ค. +> + +### โŒ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์–ด๋ ค์šด ๊ตฌ์กฐ์˜ ํŠน์ง• + +| ๋ฌธ์ œ | ์„ค๋ช… | +| --- | --- | +| **๋‚ด๋ถ€์—์„œ ์˜์กด ๊ฐ์ฒด ์ง์ ‘ ์ƒ์„ฑ (`new`)** | ํ…Œ์ŠคํŠธ ๋Œ€์—ญ์œผ๋กœ ๋Œ€์ฒด ๋ถˆ๊ฐ€ โ†’ ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ ๋ถˆ๊ฐ€๋Šฅ | +| **ํ•˜๋‚˜์˜ ํ•จ์ˆ˜๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์€ ์ฑ…์ž„** | ํ…Œ์ŠคํŠธ ๋Œ€์ƒ์ด ๋ชจํ˜ธํ•ด์ง โ†’ ์‹คํŒจ ์›์ธ ์ถ”์  ์–ด๋ ค์›€ | +| **์™ธ๋ถ€ API ํ˜ธ์ถœ, DB ์ ‘๊ทผ ๋“ฑ์ด ํ•˜๋“œ์ฝ”๋”ฉ** | ์‹ค์ œ ํ™˜๊ฒฝ ์—†์ด ํ…Œ์ŠคํŠธ ๋ถˆ๊ฐ€๋Šฅ โ†’ ๋А๋ฆฌ๊ณ  ๋ถˆ์•ˆ์ • | +| **private ๋กœ์ง, static ๋ฉ”์„œ๋“œ ๋‚จ์šฉ** | ์™ธ๋ถ€์—์„œ ๋กœ์ง ๋ถ„๋ฆฌ ๋ถˆ๊ฐ€ โ†’ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋ถˆ๊ฐ€ | + +### โœ… ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ๋ณ€๊ฒฝ + +| ํฌ์ธํŠธ | ์„ค๋ช… | +| --- | --- | +| **์™ธ๋ถ€ ์˜์กด์„ฑ ๋ถ„๋ฆฌ** | ์ธํ„ฐํŽ˜์ด์Šคํ™” + ์ƒ์„ฑ์ž ์ฃผ์ž…(DI) | +| **๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ถ„๋ฆฌ** | ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ or ์ „์šฉ Service์—์„œ ์ฑ…์ž„ ๋ถ„์‚ฐ | +| **์ฑ…์ž„ ๋‹จ์ผํ™”** | ํ•œ ํ•จ์ˆ˜๋Š” ํ•œ ์—ญํ• ๋งŒ (e.g. ๊ฒฐ์ œ๋งŒ, ์žฌ๊ณ ๋งŒ ๋“ฑ) | +| **์ƒํƒœ ์ค‘์‹ฌ ์„ค๊ณ„** | โ€œ์ž…๋ ฅ โ†’ ์ƒํƒœ ๋ณ€ํ™” โ†’ ๊ฒฐ๊ณผโ€ ๊ตฌ์กฐ๋กœ ์ •๋ฆฌ | + +### ๐Ÿ” ์‚ฌ๋ก€๋กœ ์‚ดํŽด๋ณด๊ธฐ + +```kotlin +class OrderService { + fun completeOrder(userId: Long, productId: Long) { + val user = UserJpaRepository().findById(userId) + val product = ProductJpaRepository().findById(productId) + + if (product.stock <= 0) throw IllegalStateException() + product.stock-- + + if (user.point < product.price) throw IllegalStateException() + user.point -= product.price + + OrderRepository().save(Order(user, product)) + } +} +``` + +- ์™ธ๋ถ€ ์˜์กด์„ฑ ์ง์ ‘ ์ƒ์„ฑ โ†’ Mock/Fake ๋ถˆ๊ฐ€ +- ๋„๋ฉ”์ธ ๋กœ์ง, ์ƒํƒœ๋ณ€๊ฒฝ, ์™ธ๋ถ€ ํ˜ธ์ถœ์ด ํ•œ ๊ณณ์— ๋ชฐ๋ ค ์žˆ์Œ +- `OrderServiceTest` ํ•˜๋‚˜๋กœ ๋ชจ๋“  ์ผ€์ด์Šค ์ปค๋ฒ„ํ•ด์•ผ ํ•จ โ†’ ์‹คํŒจ ์‹œ ์–ด๋””์„œ ์ž˜๋ชป๋๋Š”์ง€ ์ถ”์  ๋ถˆ๊ฐ€ + +--- + +```kotlin +class OrderService( + private val userReader: UserReader, + private val productReader: ProductReader, + private val orderRepository: OrderRepository, +) { + fun completeOrder(command: OrderCommand) { + val user = userReader.get(command.userId) + val product = productReader.get(command.productId) + + product.decreaseStock() + user.pay(product.price) + + orderRepository.save(Order(user, product)) + } +} +``` + +- ์™ธ๋ถ€๋Š” ์ธํ„ฐํŽ˜์ด์Šค๋กœ ์ฃผ์ž… โ†’ Fake/Mock ๊ฐ€๋Šฅ +- ๋กœ์ง์€ `user.pay()`, `product.decreaseStock()` ์ฒ˜๋Ÿผ ๋„๋ฉ”์ธ์œผ๋กœ ์œ„์ž„ +- ํ…Œ์ŠคํŠธ ๋‹จ์œ„๋ณ„๋กœ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ์Œ โ†’ `UserTest`, `ProductTest`, `OrderServiceTest` + +--- + +## ๐Ÿ” TDD (Test-Driven Development) + +> TDD๋Š” ํ…Œ์ŠคํŠธ์˜ ์ˆœ์„œ๋ณด๋‹ค +**โ€์„ค๊ณ„ ๋‹จ์œ„๋ฅผ ์ž˜๊ฒŒ ์ชผ๊ฐœ๊ณ , ๊ทธ๊ฒƒ์ด ๊ฒ€์ฆ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ๋Š”๊ฐ€โ€**๊ฐ€ ํ•ต์‹ฌ์ด๋‹ค. +> + +### ๐Ÿ”„ 3๋‹จ๊ณ„ ๋ฃจํ”„: Red โ†’ Green โ†’ Refactor + +``` +< ๋ฐ˜๋ณต > +1. ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ž‘์„ฑ (Red) +2. ํ†ต๊ณผํ•  ์ตœ์†Œํ•œ์˜ ์ฝ”๋“œ ์ž‘์„ฑ (Green) +3. ๊ตฌ์กฐ ๊ฐœ์„  ๋ฐ ๋ฆฌํŒฉํ† ๋ง (Refactor) +``` + +### ๐Ÿง  ๊ทธ๋Ÿฐ๋ฐ ๊ผญ ํ…Œ์ŠคํŠธ๋ฅผ ๋จผ์ € ์จ์•ผ ํ• ๊นŒ? + +| **์ „๋žต** | **์ด๋ฆ„** | **์„ค๋ช…** | +| --- | --- | --- | +| ๐Ÿงช TFD (Test First Development) | ํ…Œ์ŠคํŠธ ๋จผ์ € ์ž‘์„ฑ โ†’ ์ฝ”๋“œ๋ฅผ ๋งž์ถฐ ๊ตฌํ˜„ | ๋„๋ฉ”์ธ/๋กœ์ง ์ค‘์‹ฌ์— ์ ํ•ฉ | +| ๐Ÿ— TLD (Test Last Development) | ์ฝ”๋“œ๋ฅผ ๋จผ์ € ์ž‘์„ฑ โ†’ ํ…Œ์ŠคํŠธ๋Š” ๋‚˜์ค‘์— ์ž‘์„ฑ | API/๊ณ„์ธต ์„ค๊ณ„๊ฐ€ ๋จผ์ € ํ•„์š”ํ•œ ์ƒํ™ฉ์— ์ ํ•ฉ | + +### ๐ŸŸข TDD๊ฐ€ ํ•„์š”ํ•œ ์ด์œ  + +- **์š”๊ตฌ์‚ฌํ•ญ์„ ๋จผ์ € ์ •๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค** +- **์ž‘๊ฒŒ ์ชผ๊ฐœ๊ณ  ์ ์ง„์ ์œผ๋กœ ์„ค๊ณ„ํ•˜๊ฒŒ ๋œ๋‹ค** +- **์ธํ„ฐํŽ˜์ด์Šค ์„ค๊ณ„๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋‚˜์˜จ๋‹ค** +- **๋ฆฌํŒฉํ† ๋ง์ด ๊ฐ€๋Šฅํ•ด์ง„๋‹ค** + + + +| ๊ตฌ๋ถ„ | ๋งํฌ | +| --- | --- | +| ๐Ÿ”ข ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ | [Testing Pyramid - Martin Fowler](https://martinfowler.com/bliki/TestPyramid.html) | +| ๐Ÿงช JUnit5 | [JUnit5 ๊ณต์‹ ๋ฌธ์„œ](https://junit.org/junit5/docs/current/user-guide/) | +| โš™๏ธ Mockito | [Mockito ๊ณต์‹ ๋ฌธ์„œ](https://site.mockito.org/) | +| ๐Ÿงฐ Mockito-Kotlin | [GitHub: mockito-kotlin](https://github.com/mockito/mockito-kotlin) | +| ๐Ÿงต Spring ํ…Œ์ŠคํŠธ | [Spring Boot Testing Guide](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing) | + +> ๋ณธ ๊ณผ์ •์—์„œ๋Š” ์›ํ™œํ•œ ๋ฉ˜ํ† ๋ง์„ ์œ„ํ•ด `JUnit5 + Mockito` ๊ธฐ๋ฐ˜์œผ๋กœ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. +> + + + +> ๋‹ค์Œ ์ฃผ์—๋Š” ๋ณธ๊ฒฉ์ ์œผ๋กœ ์šฐ๋ฆฌ๋งŒ์˜ e-commerce ์‹œ์Šคํ…œ์„ **์„ค๊ณ„** ํ•ด๋ด…๋‹ˆ๋‹ค. +> \ No newline at end of file diff --git a/docs/week01_quests.md b/docs/week01_quests.md new file mode 100644 index 000000000..9d6ae8170 --- /dev/null +++ b/docs/week01_quests.md @@ -0,0 +1,131 @@ + + +# ๐Ÿ“ Round 1 Quests + +--- + +## ๐Ÿงช Implementation Quest + +> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. +> + +### ํšŒ์› ๊ฐ€์ž… + +**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** + +- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) +- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ๋‚ด ์ •๋ณด ์กฐํšŒ + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ํฌ์ธํŠธ ์กฐํšŒ + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ํฌ์ธํŠธ ์ถฉ์ „ + +**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** + +- [ ] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +## โœ… Checklist + +- [ ] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ +- [ ] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ +- [ ] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ + +--- + +## โœ๏ธ Technical Writing Quest + +> ์ด๋ฒˆ ์ฃผ์— ํ•™์Šตํ•œ ๋‚ด์šฉ, ๊ณผ์ œ ์ง„ํ–‰์„ ๋˜๋Œ์•„๋ณด๋ฉฐ +**"๋‚ด๊ฐ€ ์–ด๋–ค ํŒ๋‹จ์„ ํ•˜๊ณ  ์™œ ๊ทธ๋ ‡๊ฒŒ ๊ตฌํ˜„ํ–ˆ๋Š”์ง€"** ๋ฅผ ๊ธ€๋กœ ์ •๋ฆฌํ•ด๋ด…๋‹ˆ๋‹ค. +> +> +> **์ข‹์€ ๋ธ”๋กœ๊ทธ ๊ธ€์€ ๋‚ด๊ฐ€ ๊ฒช์€ ๋ฌธ์ œ๋ฅผ, ํƒ€์ธ๋„ ๊ณต๊ฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ์ •๋ฆฌํ•œ ๊ธ€์ž…๋‹ˆ๋‹ค.** +> +> ์ด ๊ธ€์€ ๋‹จ์ˆœ ๊ณผ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ, **ํ–ฅํ›„ ์ด์ง์— ๋„์›€์ด ๋  ์ˆ˜ ์žˆ๋Š” ํฌํŠธํด๋ฆฌ์˜ค** ๊ฐ€ ๋  ์ˆ˜ ์žˆ์–ด์š”. +> + +### ๐Ÿ“š Technical Writing Guide + +### โœ… ์ž‘์„ฑ ๊ธฐ์ค€ + +| ํ•ญ๋ชฉ | ์„ค๋ช… | +| --- | --- | +| **ํ˜•์‹** | ๋ธ”๋กœ๊ทธ | +| **๊ธธ์ด** | ์ œํ•œ ์—†์Œ, ๋‹จ ๊ผญ **1์ค„ ์š”์•ฝ (TL;DR)** ์„ ํฌํ•จํ•ด ์ฃผ์„ธ์š” | +| **ํฌ์ธํŠธ** | โ€œ๋ฌด์—‡์„ ํ–ˆ๋‹คโ€ ๋ณด๋‹ค **โ€œ์™œ ๊ทธ๋ ‡๊ฒŒ ํŒ๋‹จํ–ˆ๋Š”๊ฐ€โ€** ์ค‘์‹ฌ | +| **์˜ˆ์‹œ ํฌํ•จ** | ์ฝ”๋“œ ๋น„๊ต, ํ๋ฆ„๋„, ๋ฆฌํŒฉํ† ๋ง ์ „ํ›„ ์˜ˆ์‹œ ๋“ฑ ์ž์œ ๋กญ๊ฒŒ | +| **ํ†ค** | ์‹ค๋ ฅ์€ ๋ณด์ด์ง€๋งŒ, ์ž๋งŒํ•˜์ง€ ์•Š๊ณ , **๊ณ ๋ฏผ์ด ์ฝํžˆ๋Š” ๊ธ€**์˜ˆ: โ€œ์ฒ˜์Œ์—” mock์œผ๋กœ ์ถฉ๋ถ„ํ•˜๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ์ง€๋งŒ, ๋‚˜์ค‘์— fake๋กœ ๊ต์ฒดํ•˜๊ฒŒ ๋œ ์ด์œ ๋Š”โ€ฆโ€ | + +--- + +### โœจ ์ข‹์€ ํ†ค์€ ์ด๋Ÿฐ ๋А๋‚Œ์ด์—์š” + +> ๋‚ด๊ฐ€ ๊ฒช์€ ์‹ค์ „์  ๊ณ ๋ฏผ์„ ๋‹ค๋ฅธ ๊ฐœ๋ฐœ์ž๋„ ๊ณต๊ฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ’€์–ด๋‚ด์ž +> + +| ํŠน์ง• | ์˜ˆ์‹œ | +| --- | --- | +| ๐Ÿค” ๋‚ด ์–ธ์–ด๋กœ ์„ค๋ช…ํ•œ ๊ฐœ๋… | Stub๊ณผ Mock์˜ ์ฐจ์ด๋ฅผ ์ด๋ฒˆ ์ฃผ๋ฌธ ํ…Œ์ŠคํŠธ์—์„œ ์ฒ˜์Œ ์‹ค๊ฐํ–ˆ๋‹ค | +| ๐Ÿ’ญ ํŒ๋‹จ ํ๋ฆ„์ด ๋“œ๋Ÿฌ๋‚˜๋Š” ๊ธ€ | ์ฒ˜์Œ์—” ๋„๋ฉ”์ธ์„ ๋‚˜๋ˆ„์ง€ ์•Š์•˜๋Š”๋ฐ, ํ…Œ์ŠคํŠธ๊ฐ€ ์–ด๋ ค์›Œ์ง€๋ฉฐ ๋ถ„๋ฆฌํ–ˆ๋‹ค | +| ๐Ÿ“ ์ •๋ณด ๋‚˜์—ด๋ณด๋‹ค ์ธ์‚ฌ์ดํŠธ ์ค‘์‹ฌ | ํ…Œ์ŠคํŠธ๋Š” ์ž‘์„ฑํ–ˆ์ง€๋งŒ, ๊ตฌ์กฐ๋Š” ๋งŒ์กฑ์Šค๋Ÿฝ์ง€ ์•Š๋‹ค. ๋‹ค์Œ์—”โ€ฆ | + +### โŒ ํ”ผํ•ด์•ผ ํ•  ์Šคํƒ€์ผ + +| ์˜ˆ์‹œ | ์ด์œ  | +| --- | --- | +| ๋งŽ์ด ๋ถ€์กฑํ–ˆ๊ณ , ๋ฐ˜์„ฑํ•ฉ๋‹ˆ๋‹คโ€ฆ | ํšŒ๊ณ ๊ฐ€ ์•„๋‹ˆ๋ผ ์ผ๊ธฐ์ฒ˜๋Ÿผ ๋ณด์ž…๋‹ˆ๋‹ค | +| Stub์€ ์‘๋‹ต์„ ์ง€์ •ํ•˜๊ณ โ€ฆ | ๋‚ด ์ƒ๊ฐ์ด ์•„๋‹Œ ์š”์•ฝ๋ฌธ์ฒ˜๋Ÿผ ๋ณด์ž…๋‹ˆ๋‹ค | +| ํ…Œ์ŠคํŠธ๊ฐ€ ์ง„๋ฆฌ๋‹ค | ๋„ˆ๋ฌด ๋‹จ์ •์ ์ด๊ฑฐ๋‚˜ ์˜ค๋งŒํ•ด ๋ณด์ž…๋‹ˆ๋‹ค | + +### ๐ŸŽฏ Feature Suggestions + +- ๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ํ…Œ์ŠคํŠธ ์ค‘ ๊ฐ€์žฅ ์˜๋ฏธ ์žˆ์—ˆ๋˜ ๊ฒƒ 1๊ฐ€์ง€ +- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ํ•œ ๋ฆฌํŒฉํ† ๋ง +- Mock, Stub, Fake ์ค‘ ์‹ค์ œ ํ™œ์šฉ ๊ฒฝํ—˜๊ณผ ๋‚˜๋งŒ์˜ ๊ตฌ๋ถ„ ๊ธฐ์ค€ +- TDD ๋ฐฉ์‹์œผ๋กœ ์ ‘๊ทผํ•˜๊ฑฐ๋‚˜ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•ด๋ณด๋ฉฐ ์–ด๋ ค์› ๋˜ ์  \ No newline at end of file From 8e06929200b0d3b6ef4380c5ad54c911870ee97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 28 Oct 2025 17:04:18 +0900 Subject: [PATCH 002/164] =?UTF-8?q?round1:=20redis,=20kafka=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 4 +- .../src/main/resources/application.yml | 2 +- docker/infra-compose.yml | 174 +++++++++--------- settings.gradle.kts | 6 +- 4 files changed, 93 insertions(+), 93 deletions(-) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..e6d28d4ed 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -1,7 +1,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) - implementation(project(":modules:redis")) +// implementation(project(":modules:redis")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -18,5 +18,5 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) - testImplementation(testFixtures(project(":modules:redis"))) +// testImplementation(testFixtures(project(":modules:redis"))) } diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..a8b0b72e3 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: config: import: - jpa.yml - - redis.yml +# - redis.yml - logging.yml - monitoring.yml diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index 18e5fcf5f..d2607d47a 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -14,97 +14,97 @@ services: volumes: - mysql-8-data:/var/lib/mysql - redis-master: - image: redis:7.0 - container_name: redis-master - ports: - - "6379:6379" - volumes: - - redis_master_data:/data - command: - [ - "redis-server", # redis ์„œ๋ฒ„ ์‹คํ–‰ ๋ช…๋ น์–ด - "--appendonly", "yes", # AOF (AppendOnlyFile) ์˜์†์„ฑ ๊ธฐ๋Šฅ ์ผœ๊ธฐ - "--save", "", - "--latency-monitor-threshold", "100", # ํŠน์ • command ๊ฐ€ ์ง€์ • ์‹œ๊ฐ„(ms) ์ด์ƒ ๊ฑธ๋ฆฌ๋ฉด monitor ๊ธฐ๋ก - ] - healthcheck: - test: ["CMD", "redis-cli", "-p", "6379", "PING"] - interval: 5s - timeout: 2s - retries: 10 - - redis-readonly: - image: redis:7.0 - container_name: redis-readonly - depends_on: - redis-master: - condition: service_healthy - ports: - - "6380:6379" - volumes: - - redis_readonly_data:/data - command: - [ - "redis-server", - "--appendonly", "yes", - "--appendfsync", "everysec", - "--replicaof", "redis-master", "6379", # replica ๋ชจ๋“œ๋กœ ์‹คํ–‰ + ์„œ๋น„์Šค ๋ช…, ์„œ๋น„์Šค ํฌํŠธ - "--replica-read-only", "yes", # ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์„ค์ • - "--latency-monitor-threshold", "100", - ] - healthcheck: - test: ["CMD", "redis-cli", "-p", "6379", "PING"] - interval: 5s - timeout: 2s - retries: 10 - - kafka: - image: bitnamilegacy/kafka:3.5.1 - container_name: kafka - ports: - - "9092:9092" # ์นดํ”„์นด ๋ธŒ๋กœ์ปค PORT - - "19092:19092" # ํ˜ธ์ŠคํŠธ ๋ฆฌ์Šค๋„ˆ ์–˜ ๋–„๋ฌธ์ธ๊ฐ€ - environment: - - KAFKA_CFG_NODE_ID=1 # ๋ธŒ๋กœ์ปค ๊ณ ์œ  ID - - KAFKA_CFG_PROCESS_ROLES=broker,controller # KRaft ๋ชจ๋“œ์—ฌ์„œ, broker / controller ์—ญํ•  ๋ชจ๋‘ ๋ถ€์—ฌ - - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:19092,CONTROLLER://:9093 - # ๋ธŒ๋กœ์ปค ํด๋ผ์ด์–ธํŠธ (PLAINTEXT), ๋ธŒ๋กœ์ปค ํ˜ธ์ŠคํŠธ (PLAINTEXT) ๋‚ด๋ถ€ ์ปจํŠธ๋กค๋Ÿฌ (CONTROLLER) - - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:19092 - # ์™ธ๋ถ€ ํด๋ผ์ด์–ธํŠธ ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:9092), ๋ธŒ๋กœ์ปค ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:19092) - - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT - # ๊ฐ ๋ฆฌ์Šค๋„ˆ๋ณ„ ๋ณด์•ˆ ํ”„๋กœํ† ์ฝœ ์„ค์ • - - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT - - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER # ์ปจํŠธ๋กค๋Ÿฌ ๋‹ด๋‹น ๋ฆฌ์Šค๋„ˆ ์ง€์ • - - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@kafka:9093 # ์ปจํŠธ๋กค๋Ÿฌ ํ›„๋ณด ๋…ธ๋“œ ์ •์˜ (๋‹จ์ผ ๋ธŒ๋กœ์ปค๋ผ ์ž๊ธฐ ์ž์‹ ๋งŒ ์žˆ์Œ) - - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1 # consumer offset ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) - - KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 # transaction log ํ† ํ”ฝ ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) - - KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=1 # In-Sync-Replica ์ตœ์†Œ ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) - volumes: - - kafka-data:/bitnami/kafka - healthcheck: - test: ["CMD", "bash", "-c", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"] - interval: 10s - timeout: 5s - retries: 10 - - kafka-ui: - image: provectuslabs/kafka-ui:latest - container_name: kafka-ui - depends_on: - kafka: - condition: service_healthy - ports: - - "9099:8080" - environment: - KAFKA_CLUSTERS_0_NAME: local # kafka-ui ์—์„œ ๋ณด์ด๋Š” ํด๋Ÿฌ์Šคํ„ฐ๋ช… - KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 # kafka-ui ๊ฐ€ ์—ฐ๊ฒทํ•  ๋ธŒ๋กœ์ปค ์ฃผ์†Œ +# redis-master: +# image: redis:7.0 +# container_name: redis-master +# ports: +# - "6379:6379" +# volumes: +# - redis_master_data:/data +# command: +# [ +# "redis-server", # redis ์„œ๋ฒ„ ์‹คํ–‰ ๋ช…๋ น์–ด +# "--appendonly", "yes", # AOF (AppendOnlyFile) ์˜์†์„ฑ ๊ธฐ๋Šฅ ์ผœ๊ธฐ +# "--save", "", +# "--latency-monitor-threshold", "100", # ํŠน์ • command ๊ฐ€ ์ง€์ • ์‹œ๊ฐ„(ms) ์ด์ƒ ๊ฑธ๋ฆฌ๋ฉด monitor ๊ธฐ๋ก +# ] +# healthcheck: +# test: ["CMD", "redis-cli", "-p", "6379", "PING"] +# interval: 5s +# timeout: 2s +# retries: 10 +# +# redis-readonly: +# image: redis:7.0 +# container_name: redis-readonly +# depends_on: +# redis-master: +# condition: service_healthy +# ports: +# - "6380:6379" +# volumes: +# - redis_readonly_data:/data +# command: +# [ +# "redis-server", +# "--appendonly", "yes", +# "--appendfsync", "everysec", +# "--replicaof", "redis-master", "6379", # replica ๋ชจ๋“œ๋กœ ์‹คํ–‰ + ์„œ๋น„์Šค ๋ช…, ์„œ๋น„์Šค ํฌํŠธ +# "--replica-read-only", "yes", # ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์„ค์ • +# "--latency-monitor-threshold", "100", +# ] +# healthcheck: +# test: ["CMD", "redis-cli", "-p", "6379", "PING"] +# interval: 5s +# timeout: 2s +# retries: 10 +# +# kafka: +# image: bitnamilegacy/kafka:3.5.1 +# container_name: kafka +# ports: +# - "9092:9092" # ์นดํ”„์นด ๋ธŒ๋กœ์ปค PORT +# - "19092:19092" # ํ˜ธ์ŠคํŠธ ๋ฆฌ์Šค๋„ˆ ์–˜ ๋–„๋ฌธ์ธ๊ฐ€ +# environment: +# - KAFKA_CFG_NODE_ID=1 # ๋ธŒ๋กœ์ปค ๊ณ ์œ  ID +# - KAFKA_CFG_PROCESS_ROLES=broker,controller # KRaft ๋ชจ๋“œ์—ฌ์„œ, broker / controller ์—ญํ•  ๋ชจ๋‘ ๋ถ€์—ฌ +# - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:19092,CONTROLLER://:9093 +# # ๋ธŒ๋กœ์ปค ํด๋ผ์ด์–ธํŠธ (PLAINTEXT), ๋ธŒ๋กœ์ปค ํ˜ธ์ŠคํŠธ (PLAINTEXT) ๋‚ด๋ถ€ ์ปจํŠธ๋กค๋Ÿฌ (CONTROLLER) +# - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:19092 +# # ์™ธ๋ถ€ ํด๋ผ์ด์–ธํŠธ ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:9092), ๋ธŒ๋กœ์ปค ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:19092) +# - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT +# # ๊ฐ ๋ฆฌ์Šค๋„ˆ๋ณ„ ๋ณด์•ˆ ํ”„๋กœํ† ์ฝœ ์„ค์ • +# - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT +# - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER # ์ปจํŠธ๋กค๋Ÿฌ ๋‹ด๋‹น ๋ฆฌ์Šค๋„ˆ ์ง€์ • +# - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@kafka:9093 # ์ปจํŠธ๋กค๋Ÿฌ ํ›„๋ณด ๋…ธ๋“œ ์ •์˜ (๋‹จ์ผ ๋ธŒ๋กœ์ปค๋ผ ์ž๊ธฐ ์ž์‹ ๋งŒ ์žˆ์Œ) +# - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1 # consumer offset ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) +# - KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 # transaction log ํ† ํ”ฝ ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) +# - KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=1 # In-Sync-Replica ์ตœ์†Œ ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) +# volumes: +# - kafka-data:/bitnami/kafka +# healthcheck: +# test: ["CMD", "bash", "-c", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"] +# interval: 10s +# timeout: 5s +# retries: 10 +# +# kafka-ui: +# image: provectuslabs/kafka-ui:latest +# container_name: kafka-ui +# depends_on: +# kafka: +# condition: service_healthy +# ports: +# - "9099:8080" +# environment: +# KAFKA_CLUSTERS_0_NAME: local # kafka-ui ์—์„œ ๋ณด์ด๋Š” ํด๋Ÿฌ์Šคํ„ฐ๋ช… +# KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 # kafka-ui ๊ฐ€ ์—ฐ๊ฒทํ•  ๋ธŒ๋กœ์ปค ์ฃผ์†Œ volumes: mysql-8-data: - redis_master_data: +# redis_master_data: redis_readonly_data: - kafka-data: +# kafka-data: networks: default: diff --git a/settings.gradle.kts b/settings.gradle.kts index c99fb6360..83ff00abc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,10 +2,10 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", - ":apps:commerce-streamer", +// ":apps:commerce-streamer", ":modules:jpa", - ":modules:redis", - ":modules:kafka", +// ":modules:redis", +// ":modules:kafka", ":supports:jackson", ":supports:logging", ":supports:monitoring", From 5569bf6a83a3ad3a4884c7d6305db2d6d422d3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 28 Oct 2025 17:46:19 +0900 Subject: [PATCH 003/164] =?UTF-8?q?round1:=20User=20=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(ID)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/User.java | 19 +++++++ .../loopers/domain/user/UserModelTest.java | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 000000000..4c889f4ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,19 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public class User { + private final String id; + + private User(String id) { + this.id = id; + } + + public static User create(String id) { + if (!id.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return new User(id); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java new file mode 100644 index 000000000..06b884e8d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,51 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class UserModelTest { + @DisplayName("ํšŒ์› ๊ฐ€์ž…์„ ํ•  ๋•Œ, ") + @Nested + class Create { + // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์˜๋ฌธ ๋ฐ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ") + @Test + void throwsException_whenIdIsInvalidFormat_NotAlphanumeric() { + // arrange + String invalidId = "user!@#"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(invalidId); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ดˆ๊ณผ์ธ ๊ฒฝ์šฐ") + @Test + void throwsException_whenIdIsInvalidFormat_TooLong() { + // arrange + String invalidId = "user1234567"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(invalidId); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + // extra case + // 0์ž ์ดํ•˜์ธ ๊ฒฝ์šฐ + // ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ + } +} From ee02ce7e19e62fa36175020bc813558734368cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 28 Oct 2025 17:58:51 +0900 Subject: [PATCH 004/164] =?UTF-8?q?round1:=20User=20email=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80,=20email=20=EC=A0=9C=EC=95=BD=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/User.java | 14 +++- .../loopers/domain/user/UserModelTest.java | 71 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 4c889f4ab..355421dd1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -5,15 +5,23 @@ public class User { private final String id; + private final String email; - private User(String id) { + private User(String id, String email) { this.id = id; + this.email = email; } - public static User create(String id) { + public static User create(String id, String email) { + // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. if (!id.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } - return new User(id); + // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + if (!email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + return new User(id, email); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 06b884e8d..7ef97f12f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -12,6 +12,9 @@ public class UserModelTest { @DisplayName("ํšŒ์› ๊ฐ€์ž…์„ ํ•  ๋•Œ, ") @Nested class Create { + private final String validId = "user123"; + private final String validEmail = "xx@yy.zz"; + // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์˜๋ฌธ ๋ฐ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ") @@ -22,7 +25,7 @@ void throwsException_whenIdIsInvalidFormat_NotAlphanumeric() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId); + User.create(invalidId, validEmail); }); // assert @@ -37,7 +40,7 @@ void throwsException_whenIdIsInvalidFormat_TooLong() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId); + User.create(invalidId, validEmail); }); // assert @@ -47,5 +50,69 @@ void throwsException_whenIdIsInvalidFormat_TooLong() { // extra case // 0์ž ์ดํ•˜์ธ ๊ฒฝ์šฐ // ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ + + // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - @๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ") + @Test + void throwsException_whenEmailIsInvalidFormat_MissingAtSymbol() { + // arrange + String invalidEmail = "userexample.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, invalidEmail); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ๋„๋ฉ”์ธ ๋ถ€๋ถ„์ด ์—†๋Š” ๊ฒฝ์šฐ") + @Test + void throwsException_whenEmailIsInvalidFormat_MissingDomain() { + // arrange + String invalidEmail = "user@.com"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, invalidEmail); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ์ด ์—†๋Š” ๊ฒฝ์šฐ") + @Test + void throwsException_whenEmailIsInvalidFormat_MissingTopLevelDomain() { + // arrange + String invalidEmail = "user@example"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, invalidEmail); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - @.๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ") + @Test + void throwsException_whenEmailIsInvalidFormat_OnlyAtAndDot() { + // arrange + String invalidEmail = "@."; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, invalidEmail); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + // extra case + // ๊ณต๋ฐฑ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ } } From e1d3f05cf7bbf4bc8ac8c41a1084907d779f7338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 28 Oct 2025 18:10:32 +0900 Subject: [PATCH 005/164] =?UTF-8?q?round1:=20User=20birthday=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20birthday=20=EC=A0=9C=EC=95=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/User.java | 12 ++- .../loopers/domain/user/UserModelTest.java | 89 +++++++++++++++++-- 2 files changed, 92 insertions(+), 9 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 355421dd1..6cf45fe57 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -6,13 +6,15 @@ public class User { private final String id; private final String email; + private final String birthday; - private User(String id, String email) { + private User(String id, String email, String birthday) { this.id = id; this.email = email; + this.birthday = birthday; } - public static User create(String id, String email) { + public static User create(String id, String email, String birthday) { // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. if (!id.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); @@ -21,7 +23,11 @@ public static User create(String id, String email) { if (!email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } + // ์ƒ๋…„์›”์ผ์ด YYYY-MM-DD ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + if (!birthday.matches("^\\d{4}-\\d{2}-\\d{2}$")) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } - return new User(id, email); + return new User(id, email, birthday); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 7ef97f12f..4c29aa8db 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -14,6 +14,7 @@ public class UserModelTest { class Create { private final String validId = "user123"; private final String validEmail = "xx@yy.zz"; + private final String validBirthday = "1993-03-13"; // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. @@ -25,7 +26,7 @@ void throwsException_whenIdIsInvalidFormat_NotAlphanumeric() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail); + User.create(invalidId, validEmail, validBirthday); }); // assert @@ -40,7 +41,7 @@ void throwsException_whenIdIsInvalidFormat_TooLong() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail); + User.create(invalidId, validEmail, validBirthday); }); // assert @@ -60,7 +61,7 @@ void throwsException_whenEmailIsInvalidFormat_MissingAtSymbol() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail); + User.create(validId, invalidEmail, validBirthday); }); // assert @@ -75,7 +76,7 @@ void throwsException_whenEmailIsInvalidFormat_MissingDomain() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail); + User.create(validId, invalidEmail, validBirthday); }); // assert @@ -90,7 +91,7 @@ void throwsException_whenEmailIsInvalidFormat_MissingTopLevelDomain() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail); + User.create(validId, invalidEmail, validBirthday); }); // assert @@ -105,7 +106,7 @@ void throwsException_whenEmailIsInvalidFormat_OnlyAtAndDot() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail); + User.create(validId, invalidEmail, validBirthday); }); // assert @@ -114,5 +115,81 @@ void throwsException_whenEmailIsInvalidFormat_OnlyAtAndDot() { // extra case // ๊ณต๋ฐฑ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ + + // ์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 13-03-1993") + @Test + void throwsException_whenBirthdayIsInvalidFormat() { + // arrange + String invalidBirthday = "13-03-1993"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, validEmail, invalidBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 1993/03/13") + @Test + void throwsException_whenBirthdayIsInvalidFormat_Slashes() { + // arrange + String invalidBirthday = "1993/03/13"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, validEmail, invalidBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 19930313") + @Test + void throwsException_whenBirthdayIsInvalidFormat_NoSeparators() { + // arrange + String invalidBirthday = "19930313"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, validEmail, invalidBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - 930313") + @Test + void throwsException_whenBirthdayIsInvalidFormat_ShortDate() { + // arrange + String invalidBirthday = "930313"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, validEmail, invalidBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ๋นˆ ๋ฌธ์ž์—ด") + @Test + void throwsException_whenBirthdayIsInvalidFormat_EmptyString() { + // arrange + String invalidBirthday = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, validEmail, invalidBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } } } From b399fde2537b176e1950ebcc118f365d2063cf8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 28 Oct 2025 18:15:16 +0900 Subject: [PATCH 006/164] =?UTF-8?q?round1:=20User=20userId,=20email,=20bir?= =?UTF-8?q?thday=20null=20=EC=95=88=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/User.java | 7 +-- .../loopers/domain/user/UserModelTest.java | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 6cf45fe57..64d0e6852 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -2,6 +2,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.apache.commons.lang3.StringUtils; public class User { private final String id; @@ -16,15 +17,15 @@ private User(String id, String email, String birthday) { public static User create(String id, String email, String birthday) { // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (!id.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { + if (StringUtils.isBlank(id) || !id.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (!email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { + if (StringUtils.isBlank(email) || !email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } // ์ƒ๋…„์›”์ผ์ด YYYY-MM-DD ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (!birthday.matches("^\\d{4}-\\d{2}-\\d{2}$")) { + if (StringUtils.isBlank(birthday) || !birthday.matches("^\\d{4}-\\d{2}-\\d{2}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 4c29aa8db..58764ef7c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -18,6 +18,21 @@ class Create { // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") + @Test + void throwsException_whenIdIsInvalidFormat_Null() { + // arrange + String invalidId = null; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(invalidId, validEmail, validBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์˜๋ฌธ ๋ฐ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ") @Test void throwsException_whenIdIsInvalidFormat_NotAlphanumeric() { @@ -53,6 +68,22 @@ void throwsException_whenIdIsInvalidFormat_TooLong() { // ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") + @Test + void throwsException_whenEmailIsInvalidFormat_Null() { + // arrange + String invalidEmail = null; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, invalidEmail, validBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - @๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ") @Test void throwsException_whenEmailIsInvalidFormat_MissingAtSymbol() { @@ -117,6 +148,22 @@ void throwsException_whenEmailIsInvalidFormat_OnlyAtAndDot() { // ๊ณต๋ฐฑ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ // ์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") + @Test + void throwsException_whenBirthdayIsInvalidFormat_Null() { + // arrange + String invalidBirthday = null; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + User.create(validId, validEmail, invalidBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 13-03-1993") @Test void throwsException_whenBirthdayIsInvalidFormat() { From a143f91054fee4ac1e6d58fac3e3efd9b221640b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Wed, 29 Oct 2025 15:19:41 +0900 Subject: [PATCH 007/164] =?UTF-8?q?round1:=20User=EA=B0=80=20BaseEntity=20?= =?UTF-8?q?=EC=83=81=EC=86=8D=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,User.id=20->=20User.userId=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/user/User.java | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 64d0e6852..c4df777ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,23 +1,33 @@ package com.loopers.domain.user; +import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; import org.apache.commons.lang3.StringUtils; -public class User { - private final String id; - private final String email; - private final String birthday; +@Entity +@Table(name = "user") +@Getter +public class User extends BaseEntity { + protected User() { + } + + private String userId; + private String email; + private String birthday; - private User(String id, String email, String birthday) { - this.id = id; + private User(String userId, String email, String birthday) { + this.userId = userId; this.email = email; this.birthday = birthday; } - public static User create(String id, String email, String birthday) { + public static User create(String userId, String email, String birthday) { // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (StringUtils.isBlank(id) || !id.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { + if (StringUtils.isBlank(userId) || !userId.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. @@ -29,6 +39,6 @@ public static User create(String id, String email, String birthday) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } - return new User(id, email, birthday); + return new User(userId, email, birthday); } } From 0096dae9ecb6bbe5345dbf6972287758049c7cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Wed, 29 Oct 2025 15:20:12 +0900 Subject: [PATCH 008/164] =?UTF-8?q?round1:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. --- .../com/loopers/CommerceApiApplication.java | 3 + .../domain/user/UserJpaRepository.java | 13 ++++ .../com/loopers/domain/user/UserService.java | 30 ++++++++ .../user/UserServiceIntegrationTest.java | 70 +++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..a32927655 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,10 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + import java.util.TimeZone; @ConfigurationPropertiesScan @SpringBootApplication +@EnableJpaRepositories(basePackages = "com.loopers.domain") public class CommerceApiApplication { @PostConstruct diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java new file mode 100644 index 000000000..7f6fb8222 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserJpaRepository extends JpaRepository { + Optional findByUserId(String userId); + + boolean existsUserByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 000000000..f525d3e51 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class UserService { + private final UserJpaRepository userJpaRepository; + + @Transactional + public User registerUser(String userId, String email, String birthday) { + // ์ด๋ฏธ ๋“ฑ๋ก๋œ userId ์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค. + if (userJpaRepository.existsUserByUserId(userId)) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); + } + User user = User.create(userId, email, birthday); + return userJpaRepository.save(user); + } + + @Transactional(readOnly = true) + public Optional findByUserId(String userId) { + return userJpaRepository.findByUserId(userId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..f5dd776ae --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,70 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@Transactional +public class UserServiceIntegrationTest { + @Autowired + private UserService userService; + + @MockitoSpyBean + private UserJpaRepository spyUserRepository; + + @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") + @Test + void saveUserWhenRegister() { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + + // act + // ์œ ์ € ๋“ฑ๋ก + userService.registerUser(validId, validEmail, validBirthday); + // ์ €์žฅ๋œ ์œ ์ € ์กฐํšŒ + Optional foundUser = userService.findByUserId(validId); + + // assert + verify(spyUserRepository).save(any(User.class)); + verify(spyUserRepository).findByUserId("user123"); + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUserId()).isEqualTo("user123"); + } + + @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsExceptionWhenRegisterWithExistingUserId() { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validOtherEmail = "zz@cc.xx"; + String validOtherBirthday = "1992-06-07"; + + // act + // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก + userService.registerUser(validId, validEmail, validBirthday); + // ๋™์ผ ID ๋กœ ์œ ์ € ๋“ฑ๋ก ์‹œ๋„ + CoreException result = assertThrows(CoreException.class, () -> { + userService.registerUser(validId, validOtherEmail, validOtherBirthday); + }); + + // assert + assertThat(result.getMessage()).isEqualTo("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); + } + +} From 7f05b54b0d5c6f9f9e7b082e148b8808dedfe0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Wed, 29 Oct 2025 17:19:18 +0900 Subject: [PATCH 009/164] =?UTF-8?q?round1:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20API=20=EA=B8=B0=EB=8A=A5=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 ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. --- .../loopers/application/user/UserFacade.java | 17 +++++ .../loopers/application/user/UserInfo.java | 13 ++++ .../interfaces/api/user/UserV1ApiSpec.java | 23 ++++++ .../interfaces/api/user/UserV1Controller.java | 29 ++++++++ .../interfaces/api/user/UserV1Dto.java | 24 +++++++ .../interfaces/api/UserV1ApiE2ETest.java | 71 +++++++++++++++++++ 6 files changed, 177 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..bb5083996 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,17 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo registerUser(String userId, String email, String birthday) { + User registeredUser = userService.registerUser(userId, email, birthday); + return UserInfo.from(registeredUser); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 000000000..86cd5edf9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo(String id, String email, String birthday) { + public static UserInfo from(User user) { + return new UserInfo( + user.getUserId(), + user.getEmail(), + user.getBirthday() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 000000000..f76f204d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "์‚ฌ์šฉ์ž API ์ž…๋‹ˆ๋‹ค.") +public interface UserV1ApiSpec { + + @Operation( + method = "POST", + summary = "ํšŒ์› ๊ฐ€์ž…", + description = "ํšŒ์›๊ฐ€์ž…์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse registerUser( + @Schema( + name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", + description = "ํšŒ์› ๊ฐ€์ž…์— ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค." + ) + UserV1Dto.UserRegisterRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 000000000..4b44127e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,29 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + private final UserFacade userFacade; + + @RequestMapping(method = RequestMethod.POST) + @Override + public ApiResponse registerUser(@RequestBody UserV1Dto.UserRegisterRequest request) { + UserInfo info = userFacade.registerUser( + request.id(), + request.email(), + request.birthday() + ); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 000000000..9679a5483 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +public class UserV1Dto { + public record UserResponse( + String id, + String email, + String birthday) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.id(), + info.email(), + info.birthday() + ); + } + } + + public record UserRegisterRequest( + String id, + String email, + String birthday) { + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 000000000..2531560ee --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class UserV1ApiE2ETest { + + private final String ENDPOINT_USER = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class Post { + @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnUserInfo_whenRegisterSuccess() { + // arrange + UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( + "user123", + "xx@yy.zz", + "1993-03-13" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(request.id()), + () -> assertThat(response.getBody().data().email()).isEqualTo(request.email()), + () -> assertThat(response.getBody().data().birthday()).isEqualTo(request.birthday()) + ); + } + + } +} From ca1fed1b1937888d84ea8736b8072303627330dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 31 Oct 2025 07:06:31 +0900 Subject: [PATCH 010/164] =?UTF-8?q?round1:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=84=B1=EB=B3=84=EC=9A=94=EA=B1=B4=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 ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. --- .../loopers/application/user/UserFacade.java | 4 +-- .../loopers/application/user/UserInfo.java | 9 +++--- .../java/com/loopers/domain/user/User.java | 12 ++++++-- .../com/loopers/domain/user/UserService.java | 4 +-- .../interfaces/api/user/UserV1Controller.java | 8 ++--- .../interfaces/api/user/UserV1Dto.java | 9 ++++-- .../loopers/domain/user/UserModelTest.java | 29 ++++++++++--------- .../user/UserServiceIntegrationTest.java | 10 +++---- .../interfaces/api/UserV1ApiE2ETest.java | 27 +++++++++++++++-- 9 files changed, 72 insertions(+), 40 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index bb5083996..0037eeca5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -10,8 +10,8 @@ public class UserFacade { private final UserService userService; - public UserInfo registerUser(String userId, String email, String birthday) { - User registeredUser = userService.registerUser(userId, email, birthday); + public UserInfo registerUser(String userId, String email, String birthday, String gender) { + User registeredUser = userService.registerUser(userId, email, birthday, gender); return UserInfo.from(registeredUser); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index 86cd5edf9..84cda840f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -2,12 +2,13 @@ import com.loopers.domain.user.User; -public record UserInfo(String id, String email, String birthday) { +public record UserInfo(String id, String email, String birthday, String gender) { public static UserInfo from(User user) { return new UserInfo( - user.getUserId(), - user.getEmail(), - user.getBirthday() + user.getUserId(), + user.getEmail(), + user.getBirthday(), + user.getGender() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index c4df777ee..a1074a66f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -18,14 +18,16 @@ protected User() { private String userId; private String email; private String birthday; + private String gender; - private User(String userId, String email, String birthday) { + private User(String userId, String email, String birthday, String gender) { this.userId = userId; this.email = email; this.birthday = birthday; + this.gender = gender; } - public static User create(String userId, String email, String birthday) { + public static User create(String userId, String email, String birthday, String gender) { // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. if (StringUtils.isBlank(userId) || !userId.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); @@ -38,7 +40,11 @@ public static User create(String userId, String email, String birthday) { if (StringUtils.isBlank(birthday) || !birthday.matches("^\\d{4}-\\d{2}-\\d{2}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } + // ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + if (StringUtils.isBlank(gender)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ฑ๋ณ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค."); + } - return new User(userId, email, birthday); + return new User(userId, email, birthday, gender); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index f525d3e51..8f45495e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -14,12 +14,12 @@ public class UserService { private final UserJpaRepository userJpaRepository; @Transactional - public User registerUser(String userId, String email, String birthday) { + public User registerUser(String userId, String email, String birthday, String gender) { // ์ด๋ฏธ ๋“ฑ๋ก๋œ userId ์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค. if (userJpaRepository.existsUserByUserId(userId)) { throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); } - User user = User.create(userId, email, birthday); + User user = User.create(userId, email, birthday, gender); return userJpaRepository.save(user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 4b44127e2..5a349a998 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -4,10 +4,7 @@ import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController @@ -21,7 +18,8 @@ public ApiResponse registerUser(@RequestBody UserV1Dto.U UserInfo info = userFacade.registerUser( request.id(), request.email(), - request.birthday() + request.birthday(), + request.gender() ); UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); return ApiResponse.success(response); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 9679a5483..a6500f737 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -6,12 +6,14 @@ public class UserV1Dto { public record UserResponse( String id, String email, - String birthday) { + String birthday, + String gender) { public static UserResponse from(UserInfo info) { return new UserResponse( info.id(), info.email(), - info.birthday() + info.birthday(), + info.gender() ); } } @@ -19,6 +21,7 @@ public static UserResponse from(UserInfo info) { public record UserRegisterRequest( String id, String email, - String birthday) { + String birthday, + String gender) { } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 58764ef7c..45adade80 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -15,6 +15,7 @@ class Create { private final String validId = "user123"; private final String validEmail = "xx@yy.zz"; private final String validBirthday = "1993-03-13"; + private final String validGender = "male"; // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. @@ -26,7 +27,7 @@ void throwsException_whenIdIsInvalidFormat_Null() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail, validBirthday); + User.create(invalidId, validEmail, validBirthday, validGender); }); // assert @@ -41,7 +42,7 @@ void throwsException_whenIdIsInvalidFormat_NotAlphanumeric() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail, validBirthday); + User.create(invalidId, validEmail, validBirthday, validGender); }); // assert @@ -56,7 +57,7 @@ void throwsException_whenIdIsInvalidFormat_TooLong() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail, validBirthday); + User.create(invalidId, validEmail, validBirthday, validGender); }); // assert @@ -77,7 +78,7 @@ void throwsException_whenEmailIsInvalidFormat_Null() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday); + User.create(validId, invalidEmail, validBirthday, validGender); }); // assert @@ -92,7 +93,7 @@ void throwsException_whenEmailIsInvalidFormat_MissingAtSymbol() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday); + User.create(validId, invalidEmail, validBirthday, validGender); }); // assert @@ -107,7 +108,7 @@ void throwsException_whenEmailIsInvalidFormat_MissingDomain() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday); + User.create(validId, invalidEmail, validBirthday, validGender); }); // assert @@ -122,7 +123,7 @@ void throwsException_whenEmailIsInvalidFormat_MissingTopLevelDomain() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday); + User.create(validId, invalidEmail, validBirthday, validGender); }); // assert @@ -137,7 +138,7 @@ void throwsException_whenEmailIsInvalidFormat_OnlyAtAndDot() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday); + User.create(validId, invalidEmail, validBirthday, validGender); }); // assert @@ -157,7 +158,7 @@ void throwsException_whenBirthdayIsInvalidFormat_Null() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday); + User.create(validId, validEmail, invalidBirthday, validGender); }); // assert @@ -172,7 +173,7 @@ void throwsException_whenBirthdayIsInvalidFormat() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday); + User.create(validId, validEmail, invalidBirthday, validGender); }); // assert @@ -187,7 +188,7 @@ void throwsException_whenBirthdayIsInvalidFormat_Slashes() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday); + User.create(validId, validEmail, invalidBirthday, validGender); }); // assert @@ -202,7 +203,7 @@ void throwsException_whenBirthdayIsInvalidFormat_NoSeparators() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday); + User.create(validId, validEmail, invalidBirthday, validGender); }); // assert @@ -217,7 +218,7 @@ void throwsException_whenBirthdayIsInvalidFormat_ShortDate() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday); + User.create(validId, validEmail, invalidBirthday, validGender); }); // assert @@ -232,7 +233,7 @@ void throwsException_whenBirthdayIsInvalidFormat_EmptyString() { // act CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday); + User.create(validId, validEmail, invalidBirthday, validGender); }); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index f5dd776ae..d12e12a4f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -31,10 +31,11 @@ void saveUserWhenRegister() { String validId = "user123"; String validEmail = "xx@yy.zz"; String validBirthday = "1993-03-13"; + String validGender = "male"; // act // ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday); + userService.registerUser(validId, validEmail, validBirthday, validGender); // ์ €์žฅ๋œ ์œ ์ € ์กฐํšŒ Optional foundUser = userService.findByUserId(validId); @@ -52,15 +53,14 @@ void throwsExceptionWhenRegisterWithExistingUserId() { String validId = "user123"; String validEmail = "xx@yy.zz"; String validBirthday = "1993-03-13"; - String validOtherEmail = "zz@cc.xx"; - String validOtherBirthday = "1992-06-07"; + String validGender = "male"; // act // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday); + userService.registerUser(validId, validEmail, validBirthday, validGender); // ๋™์ผ ID ๋กœ ์œ ์ € ๋“ฑ๋ก ์‹œ๋„ CoreException result = assertThrows(CoreException.class, () -> { - userService.registerUser(validId, validOtherEmail, validOtherBirthday); + userService.registerUser(validId, "zz@cc.xx", "1992-06-07", "female"); }); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 2531560ee..4e5f97b13 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -50,7 +50,8 @@ void returnUserInfo_whenRegisterSuccess() { UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( "user123", "xx@yy.zz", - "1993-03-13" + "1993-03-13", + "male" ); // act @@ -63,9 +64,31 @@ void returnUserInfo_whenRegisterSuccess() { () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody().data().id()).isEqualTo(request.id()), () -> assertThat(response.getBody().data().email()).isEqualTo(request.email()), - () -> assertThat(response.getBody().data().birthday()).isEqualTo(request.birthday()) + () -> assertThat(response.getBody().data().birthday()).isEqualTo(request.birthday()), + () -> assertThat(response.getBody().data().gender()).isEqualTo(request.gender()) ); } + // ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenGenderIsMissing() { + // arrange + UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( + "user123", + "xx@yy.zz", + "1993-03-13", + null + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + } } From d5280eafe4f85b6af14bb8641c4f3b1925a2d140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 31 Oct 2025 07:15:35 +0900 Subject: [PATCH 011/164] =?UTF-8?q?round1:=20=EB=82=B4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=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 ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. --- .../loopers/application/user/UserFacade.java | 9 +++ .../interfaces/api/user/UserV1ApiSpec.java | 14 +++++ .../interfaces/api/user/UserV1Controller.java | 8 +++ .../user/UserServiceIntegrationTest.java | 39 ++++++++++++ .../interfaces/api/UserV1ApiE2ETest.java | 62 +++++++++++++++++-- 5 files changed, 128 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 0037eeca5..848eed169 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -2,9 +2,13 @@ import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Optional; + @RequiredArgsConstructor @Component public class UserFacade { @@ -14,4 +18,9 @@ public UserInfo registerUser(String userId, String email, String birthday, Strin User registeredUser = userService.registerUser(userId, email, birthday, gender); return UserInfo.from(registeredUser); } + + public UserInfo getUserInfo(String userId) { + Optional user = userService.findByUserId(userId); + return UserInfo.from(user.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."))); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index f76f204d5..bb1a413c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -20,4 +20,18 @@ ApiResponse registerUser( ) UserV1Dto.UserRegisterRequest request ); + + @Operation( + method = "GET", + summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", + description = "ํšŒ์› ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getUserInfo( + @Schema( + name = "ํšŒ์› ID", + description = "์กฐํšŒํ•  ํšŒ์›์˜ ID" + ) + String userId + ); + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 5a349a998..059faf73f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -24,4 +24,12 @@ public ApiResponse registerUser(@RequestBody UserV1Dto.U UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); return ApiResponse.success(response); } + + @RequestMapping(method = RequestMethod.GET, path = "/{userId}") + @Override + public ApiResponse getUserInfo(@PathVariable String userId) { + UserInfo info = userFacade.getUserInfo(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index d12e12a4f..24bf57b2c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -67,4 +67,43 @@ void throwsExceptionWhenRegisterWithExistingUserId() { assertThat(result.getMessage()).isEqualTo("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); } + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsUserInfo_whenUserExists() { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validGender = "male"; + // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก + userService.registerUser(validId, validEmail, validBirthday, validGender); + + // act + Optional foundUser = userService.findByUserId(validId); + + // assert + assertThat(foundUser).isPresent(); + assertThat(foundUser.get().getUserId()).isEqualTo("user123"); + } + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsNull_whenUserDoesNotExist() { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validGender = "male"; + // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก + userService.registerUser(validId, validEmail, validBirthday, validGender); + String nonExistId = "nonexist"; + + + // act + Optional foundUser = userService.findByUserId(nonExistId); + + // assert + assertThat(foundUser).isNotPresent(); + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 4e5f97b13..ebba3f89d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -2,10 +2,7 @@ import com.loopers.interfaces.api.user.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; @@ -91,4 +88,61 @@ void returnBadRequest_whenGenderIsMissing() { } } + + @DisplayName("GET /api/v1/users/{userId}") + @Nested + class Get { + private final String validUserId = "user123"; + private final String validEmail = "xx@yy.zz"; + private final String validBirthday = "1993-03-13"; + private final String validGender = "male"; + + // ํšŒ์›๊ฐ€์ž… ์ •๋ณด ์ž‘์„ฑ + @BeforeEach + void setupUser() { + UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( + validUserId, + validEmail, + validBirthday, + validGender + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); + } + + @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnUserInfo_whenGetUserInfoSuccess() { + // arrange: setupUser() ์ฐธ์กฐ + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/" + validUserId, HttpMethod.GET, null, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo("user123"), + () -> assertThat(response.getBody().data().email()).isEqualTo("xx@yy.zz"), + () -> assertThat(response.getBody().data().birthday()).isEqualTo("1993-03-13"), + () -> assertThat(response.getBody().data().gender()).isEqualTo("male") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + String invalidUserId = "nonexist"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/" + invalidUserId, HttpMethod.GET, null, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + } } From 05b7b2a7471b87151eac4d468bec4e624fda2693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 31 Oct 2025 15:20:10 +0900 Subject: [PATCH 012/164] =?UTF-8?q?round1:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. --- .../application/point/PointFacade.java | 18 ++++ .../java/com/loopers/domain/user/User.java | 1 + .../com/loopers/domain/user/UserService.java | 5 + .../interfaces/api/point/PointV1ApiSpec.java | 25 +++++ .../api/point/PointV1Controller.java | 31 ++++++ .../interfaces/api/point/PointV1Dto.java | 14 +++ .../user/UserServiceIntegrationTest.java | 38 +++++++ .../interfaces/api/PointV1ApiE2ETest.java | 102 ++++++++++++++++++ 8 files changed, 234 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java new file mode 100644 index 000000000..ac25b521c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -0,0 +1,18 @@ +package com.loopers.application.point; + +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PointFacade { + private final UserService userService; + + public Long getCurrentPoint(String userId) { + return userService.getCurrentPoint(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index a1074a66f..f0d617ddf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -19,6 +19,7 @@ protected User() { private String email; private String birthday; private String gender; + private Long currentPoint = 0L; private User(String userId, String email, String birthday, String gender) { this.userId = userId; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 8f45495e1..c0f70b1d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -27,4 +27,9 @@ public User registerUser(String userId, String email, String birthday, String ge public Optional findByUserId(String userId) { return userJpaRepository.findByUserId(userId); } + + @Transactional(readOnly = true) + public Optional getCurrentPoint(String userId) { + return findByUserId(userId).map(User::getCurrentPoint); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java new file mode 100644 index 000000000..8efd0d8fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Point V1 API", description = "์‚ฌ์šฉ์ž API ์ž…๋‹ˆ๋‹ค.") +public interface PointV1ApiSpec { + + // /points + @Operation( + method = "GET", + summary = "ํฌ์ธํŠธ ์กฐํšŒ", + description = "ํšŒ์›์˜ ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + // X-USER-ID ํ—ค๋”๊ฐ’ ์‚ฌ์šฉ + ApiResponse getUserPoints( + @Schema( + name = "ํšŒ์› ID", + description = "ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•  ํšŒ์›์˜ ID" + ) + String userId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..d5530e9c9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.point; + + +import com.loopers.application.point.PointFacade; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/points") +public class PointV1Controller implements PointV1ApiSpec { + private final PointFacade pointFacade; + + @RequestMapping(method = RequestMethod.GET) + @Override + public ApiResponse getUserPoints(@RequestHeader(value = "X-USER-ID", required = false) String userId) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + Long currentPoint = pointFacade.getCurrentPoint(userId); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(currentPoint); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 000000000..c90bb7072 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.point; + +public class PointV1Dto { + + public record PointResponse( + Long currentPoint + ) { + public static PointV1Dto.PointResponse from(Long currentPoint) { + return new PointV1Dto.PointResponse( + currentPoint + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 24bf57b2c..b7698e76c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -106,4 +106,42 @@ void returnsNull_whenUserDoesNotExist() { assertThat(foundUser).isNotPresent(); } + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsUserPoints_whenUserExists() { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validGender = "male"; + // ์œ ์ € ๋“ฑ๋ก + userService.registerUser(validId, validEmail, validBirthday, validGender); + String existingUserId = "user123"; + + // act + Optional currentPoint = userService.getCurrentPoint(existingUserId); + + // assert + assertThat(currentPoint).isPresent(); + assertThat(currentPoint.get()).isEqualTo(0L); + } + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsNullPoints_whenUserDoesNotExist() { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validGender = "male"; + // ์œ ์ € ๋“ฑ๋ก + userService.registerUser(validId, validEmail, validBirthday, validGender); + String nonExistingId = "nonexist"; + + // act + Optional currentPoint = userService.getCurrentPoint(nonExistingId); + + // assert + assertThat(currentPoint).isNotPresent(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java new file mode 100644 index 000000000..e31f5e604 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.point.PointV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class PointV1ApiE2ETest { + private final String ENDPOINT_USER = "/api/v1/users"; + private final String ENDPOINT_POINT = "/api/v1/points"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public PointV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/points") + @Nested + class GetPoints { + private final String validUserId = "user123"; + private final String validEmail = "xx@yy.zz"; + private final String validBirthday = "1993-03-13"; + private final String validGender = "male"; + + // ํšŒ์›๊ฐ€์ž… ์ •๋ณด ์ž‘์„ฑ + @BeforeEach + void setupUser() { + UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( + validUserId, + validEmail, + validBirthday, + validGender + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); + } + + @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnUserPoints_whenGetUserPointsSuccess() { + // arrange: setupUser() ์ฐธ์กฐ + String xUserIdHeader = "user123"; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", xUserIdHeader); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().currentPoint()).isEqualTo(0L) + ); + } + + //`X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + @DisplayName("`X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange: setupUser() ์ฐธ์กฐ + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, null); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + } + +} From 227d93260d2349c04b99731db0db6f06103cb53b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 31 Oct 2025 17:00:27 +0900 Subject: [PATCH 013/164] =?UTF-8?q?round1:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=A9=EC=A0=84=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. --- .../application/point/PointFacade.java | 6 +++ .../java/com/loopers/domain/point/Point.java | 35 ++++++++++++++++ .../domain/point/PointJpaRepository.java | 9 ++++ .../loopers/domain/point/PointService.java | 28 +++++++++++++ .../java/com/loopers/domain/user/User.java | 4 ++ .../com/loopers/domain/user/UserService.java | 5 +++ .../interfaces/api/point/PointV1ApiSpec.java | 19 +++++++++ .../api/point/PointV1Controller.java | 15 +++++-- .../interfaces/api/point/PointV1Dto.java | 5 +++ .../loopers/domain/point/PointModelTest.java | 37 ++++++++++++++++ .../point/PointServiceIntegrationTest.java | 30 +++++++++++++ .../interfaces/api/PointV1ApiE2ETest.java | 42 +++++++++++++++++++ 12 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java index ac25b521c..1a9af650f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.point; +import com.loopers.domain.point.PointService; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -10,9 +11,14 @@ @Component public class PointFacade { private final UserService userService; + private final PointService pointService; public Long getCurrentPoint(String userId) { return userService.getCurrentPoint(userId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + public Long chargePoints(String userId, int amount) { + return pointService.chargePoint(userId, amount); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java new file mode 100644 index 000000000..d27ebbe22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -0,0 +1,35 @@ +package com.loopers.domain.point; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "point") +public class Point extends BaseEntity { + @ManyToOne + @JoinColumn(referencedColumnName = "id", nullable = false, updatable = false) + private User user; + private Long amount; + + protected Point() { + } + + private Point(User user, Long amount) { + this.user = user; + this.amount = amount; + } + + public static Point create(User user, int amount) { + if (amount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + return new Point(user, (long) amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java new file mode 100644 index 000000000..2c1e365a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.point; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PointJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java new file mode 100644 index 000000000..ce0960041 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,28 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class PointService { + private final PointJpaRepository pointJpaRepository; + private final UserService userService; + + @Transactional + public Long chargePoint(String userId, int amount) { + User user = userService.findByUserIdForUpdate(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + Point point = Point.create(user, amount); + + user.setCurrentPoint(user.getCurrentPoint() + amount); + pointJpaRepository.save(point); + + return user.getCurrentPoint(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index f0d617ddf..908a5efac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -3,9 +3,11 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.Getter; +import lombok.Setter; import org.apache.commons.lang3.StringUtils; @Entity @@ -15,10 +17,12 @@ public class User extends BaseEntity { protected User() { } + @Column(nullable = false, unique = true, length = 10) private String userId; private String email; private String birthday; private String gender; + @Setter private Long currentPoint = 0L; private User(String userId, String email, String birthday, String gender) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index c0f70b1d3..be907604a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -28,6 +28,11 @@ public Optional findByUserId(String userId) { return userJpaRepository.findByUserId(userId); } + // find by user id with lock for update + @Transactional + public Optional findByUserIdForUpdate(String userId) { + return userJpaRepository.findByUserId(userId); + } @Transactional(readOnly = true) public Optional getCurrentPoint(String userId) { return findByUserId(userId).map(User::getCurrentPoint); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java index 8efd0d8fb..bfb935ad2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -22,4 +22,23 @@ ApiResponse getUserPoints( ) String userId ); + + // /points post ํฌ์ธํŠธ ์ถฉ์ „ + @Operation( + method = "POST", + summary = "ํฌ์ธํŠธ ์ถฉ์ „", + description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse chargeUserPoints( + @Schema( + name = "ํšŒ์› ID", + description = "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•  ํšŒ์›์˜ ID" + ) + String userId, + @Schema( + name = "์ถฉ์ „ํ•  ํฌ์ธํŠธ", + description = "์ถฉ์ „ํ•  ํฌ์ธํŠธ ๊ธˆ์•ก. ์–‘์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค." + ) + PointV1Dto.PointChargeRequest request + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index d5530e9c9..d2fb3ce63 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -7,10 +7,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @RestController @@ -28,4 +25,14 @@ public ApiResponse getUserPoints(@RequestHeader(value PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(currentPoint); return ApiResponse.success(response); } + + @RequestMapping(method = RequestMethod.POST) + @Override + public ApiResponse chargeUserPoints( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @RequestBody PointV1Dto.PointChargeRequest request) { + Long chargedPoint = pointFacade.chargePoints(userId, request.amount()); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(chargedPoint); + return ApiResponse.success(response); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java index c90bb7072..a42ddec01 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -11,4 +11,9 @@ public static PointV1Dto.PointResponse from(Long currentPoint) { ); } } + + public record PointChargeRequest( + int amount + ) { + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java new file mode 100644 index 000000000..6eb3f5f72 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -0,0 +1,37 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class PointModelTest { + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „์„ ํ•  ๋•Œ, ") + @Nested + class Create { + // 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. + @DisplayName("0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค.") + @ParameterizedTest + @ValueSource(ints = {0, -10, -100}) + void throwsException_whenPointIsZeroOrNegative(int invalidPoint) { + // arrange + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validGender = "male"; + + User user = User.create(validId, validEmail, validBirthday, validGender); + + // act + CoreException result = assertThrows(CoreException.class, () -> Point.create(user, invalidPoint)); + + // assert + assertThat(result.getMessage()).isEqualTo("์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java new file mode 100644 index 000000000..a2097aef9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -0,0 +1,30 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@Transactional +public class PointServiceIntegrationTest { + @Autowired + private PointService pointService; + + //์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsExceptionWhenChargePointWithNonExistingUserId() { + // arrange + String nonExistingUserId = "nonexist"; + int chargeAmount = 1000; + + // act & assert + assertThrows(CoreException.class, () -> pointService.chargePoint(nonExistingUserId, chargeAmount)); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java index e31f5e604..b4dd08e3c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -97,6 +97,48 @@ void returnBadRequest_whenXUserIdHeaderIsMissing() { // assert assertThat(response.getStatusCode().value()).isEqualTo(400); } + + @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnChargedPoints_whenChargeUserPointsSuccess() { + // arrange: setupUser() ์ฐธ์กฐ + String xUserIdHeader = "user123"; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", xUserIdHeader); + PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(request, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.POST, requestEntity, responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().currentPoint()).isEqualTo(1000L) + ); + } + + //์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenChargePointsForNonExistentUser() { + // arrange: setupUser() ์ฐธ์กฐ + String xUserIdHeader = "nonexist"; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", xUserIdHeader); + PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(request, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.POST, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } } } From 003b54351c073361cfd9272616e050f3737dc80c Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Tue, 28 Oct 2025 02:30:05 +0900 Subject: [PATCH 014/164] =?UTF-8?q?docs:=201round.md=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/1round.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 docs/1round.md diff --git a/docs/1round.md b/docs/1round.md new file mode 100644 index 000000000..922b0bcea --- /dev/null +++ b/docs/1round.md @@ -0,0 +1,67 @@ +## ๐Ÿงช Implementation Quest + +> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. +> + +### ํšŒ์› ๊ฐ€์ž… + +**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** + +- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) +- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ๋‚ด ์ •๋ณด ์กฐํšŒ + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ํฌ์ธํŠธ ์กฐํšŒ + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ํฌ์ธํŠธ ์ถฉ์ „ + +**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** + +- [ ] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [ ] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +## โœ… Checklist + +- [ ] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ +- [ ] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ +- [ ] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ \ No newline at end of file From 00498cd9969b6df9d026a270376da4d27c2f85a5 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Tue, 28 Oct 2025 03:22:55 +0900 Subject: [PATCH 015/164] =?UTF-8?q?feat:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=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/loopers/domain/user/User.java | 13 +++++++++ .../com/loopers/domain/user/UserBirth.java | 26 ++++++++++++++++++ .../com/loopers/domain/user/UserEmail.java | 27 +++++++++++++++++++ .../java/com/loopers/domain/user/UserId.java | 24 +++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 000000000..9e782a7e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,13 @@ +package com.loopers.domain.user; + +public class User { + private final UserId userId; + private final UserEmail email; + private final UserBirth birth; + + public User(UserId userId, UserEmail email, UserBirth birth) { + this.userId = userId; + this.email = email; + this.birth = birth; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java new file mode 100644 index 000000000..0bdc950e3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java @@ -0,0 +1,26 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public class UserBirth { + + private static final Pattern PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + + + private final String birth; + + public UserBirth(String birth) { + if (birth == null || birth.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if(!PATTERN.matcher(birth).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + this.birth = birth; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java new file mode 100644 index 000000000..a3005c312 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java @@ -0,0 +1,27 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public class UserEmail { + + private final static Pattern PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); + + private String email; + + public UserEmail(String email) { + if(email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if(!PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์— ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ex)xx@yy.zz"); + } + + this.email = email; + } + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java new file mode 100644 index 000000000..9adb13bbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java @@ -0,0 +1,24 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.regex.Pattern; + +public class UserId { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); + + private final String id; + + public UserId(String id) { + if(id == null || id.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (!PATTERN.matcher(id).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + this.id = id; + } +} From 1ad67daafe6c9ae1398249e339b0c9bb20632869 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Tue, 28 Oct 2025 03:23:37 +0900 Subject: [PATCH 016/164] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/user/UserBirthTest.java | 25 +++++++++++++++ .../loopers/domain/user/UserEmailTest.java | 31 +++++++++++++++++++ .../com/loopers/domain/user/UserIdTest.java | 31 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java new file mode 100644 index 000000000..7d603fa04 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java @@ -0,0 +1,25 @@ +package com.loopers.domain.user; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserBirth Test") +class UserBirthTest { + @DisplayName("UserBirth ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + @DisplayName("UserBirth์ด ํ˜•์‹์— ๋งž์œผ๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createUserBirth_whenValid() { + String birth = "1994-12-01"; + + UserBirth userBirth = new UserBirth(birth); + + assertThat(userBirth).extracting("birth").isEqualTo(birth); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java new file mode 100644 index 000000000..10a527b42 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java @@ -0,0 +1,31 @@ +package com.loopers.domain.user; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserEmail Test") +class UserEmailTest { + + @DisplayName("UserEmail ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + @DisplayName("UserEmail์ด ํ˜•์‹์— ๋งž์œผ๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createUserEmail_whenValid() { + + // given + String email = "yh45g@gmail.com"; + + // when + UserEmail userEmail = new UserEmail(email); + + // then + Assertions.assertThat(userEmail).extracting("email").isEqualTo(email); + + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java new file mode 100644 index 000000000..67e23e206 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java @@ -0,0 +1,31 @@ +package com.loopers.domain.user; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserId Test") +class UserIdTest { + + @DisplayName("UserId ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + @DisplayName("userId๊ฐ€ ํ˜•์‹์— ๋งž์œผ๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createUserId_whenValid() { + + // given + String id = "yh45g"; + + // when + UserId userId = new UserId(id); + + // then + Assertions.assertThat(userId).extracting("id").isEqualTo(id); + + } + } +} From 4a1336604a8f08fcc0ada12fe21244770040feb3 Mon Sep 17 00:00:00 2001 From: bookers-web Date: Tue, 28 Oct 2025 18:57:19 +0900 Subject: [PATCH 017/164] =?UTF-8?q?feat=20:=20User=20=EA=B0=92=EA=B0=9D?= =?UTF-8?q?=EC=B2=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=ED=9B=84=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/user/UserBirth.java | 26 ---------------- .../com/loopers/domain/user/UserEmail.java | 27 ---------------- .../java/com/loopers/domain/user/UserId.java | 24 -------------- .../loopers/domain/user/UserBirthTest.java | 25 --------------- .../loopers/domain/user/UserEmailTest.java | 31 ------------------- .../com/loopers/domain/user/UserIdTest.java | 31 ------------------- 6 files changed, 164 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java deleted file mode 100644 index 0bdc950e3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserBirth.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; - -import java.util.regex.Pattern; - -public class UserBirth { - - private static final Pattern PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); - - - private final String birth; - - public UserBirth(String birth) { - if (birth == null || birth.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if(!PATTERN.matcher(birth).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - this.birth = birth; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java deleted file mode 100644 index a3005c312..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserEmail.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; - -import java.util.regex.Pattern; - -public class UserEmail { - - private final static Pattern PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); - - private String email; - - public UserEmail(String email) { - if(email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if(!PATTERN.matcher(email).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์— ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ex)xx@yy.zz"); - } - - this.email = email; - } - - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java deleted file mode 100644 index 9adb13bbd..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; - -import java.util.regex.Pattern; - -public class UserId { - - private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); - - private final String id; - - public UserId(String id) { - if(id == null || id.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if (!PATTERN.matcher(id).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - this.id = id; - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java deleted file mode 100644 index 7d603fa04..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserBirthTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.domain.user; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("UserBirth Test") -class UserBirthTest { - @DisplayName("UserBirth ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ") - @Nested - class Create { - @DisplayName("UserBirth์ด ํ˜•์‹์— ๋งž์œผ๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") - @Test - void createUserBirth_whenValid() { - String birth = "1994-12-01"; - - UserBirth userBirth = new UserBirth(birth); - - assertThat(userBirth).extracting("birth").isEqualTo(birth); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java deleted file mode 100644 index 10a527b42..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserEmailTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.domain.user; - -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("UserEmail Test") -class UserEmailTest { - - @DisplayName("UserEmail ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ") - @Nested - class Create { - @DisplayName("UserEmail์ด ํ˜•์‹์— ๋งž์œผ๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") - @Test - void createUserEmail_whenValid() { - - // given - String email = "yh45g@gmail.com"; - - // when - UserEmail userEmail = new UserEmail(email); - - // then - Assertions.assertThat(userEmail).extracting("email").isEqualTo(email); - - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java deleted file mode 100644 index 67e23e206..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserIdTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.domain.user; - -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -@DisplayName("UserId Test") -class UserIdTest { - - @DisplayName("UserId ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ") - @Nested - class Create { - @DisplayName("userId๊ฐ€ ํ˜•์‹์— ๋งž์œผ๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") - @Test - void createUserId_whenValid() { - - // given - String id = "yh45g"; - - // when - UserId userId = new UserId(id); - - // then - Assertions.assertThat(userId).extracting("id").isEqualTo(id); - - } - } -} From bc962b2dae67d277e3e5a57fd2589fe898961576 Mon Sep 17 00:00:00 2001 From: bookers-web Date: Tue, 28 Oct 2025 18:57:33 +0900 Subject: [PATCH 018/164] =?UTF-8?q?feat=20:=20User=20=EA=B0=9D=EC=B2=B4=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/loopers/domain/user/User.java | 68 ++++++++++++++++--- .../com/loopers/domain/user/UserTest.java | 18 +++++ 2 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 9e782a7e5..fec224c80 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,13 +1,63 @@ package com.loopers.domain.user; -public class User { - private final UserId userId; - private final UserEmail email; - private final UserBirth birth; - - public User(UserId userId, UserEmail email, UserBirth birth) { - this.userId = userId; - this.email = email; - this.birth = birth; +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +import java.util.regex.Pattern; + +@Entity +@Table(name = "user") +public class User extends BaseEntity { + + private static final Pattern USERID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); + private static final Pattern BIRTH_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); + + private String userId; + private String email; + private String birth; + + protected User() {} + + public User(String userId, String email, String birth) { + this.userId = requireValidUserId(userId); + this.email = requireValidEmail(email); + this.birth = requireValidBirthDate(birth); + } + + String requireValidUserId(String userId) { + if(userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (!USERID_PATTERN.matcher(userId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return userId; + } + + String requireValidEmail(String email) { + if(email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if(!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์— ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ex)xx@yy.zz"); + } + return email; + } + + String requireValidBirthDate(String birth) { + if (birth == null || birth.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if(!BIRTH_PATTERN.matcher(birth).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return birth; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 000000000..1d938accc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,18 @@ +package com.loopers.domain.user; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * packageName : com.loopers.domain.user + * fileName : UserTest + * author : byeonsungmun + * date : 25. 10. 28. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 25. 10. 28. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class UserTest { + +} From 0605dc42e8612898d0ad5ca4d094d09e0b158283 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Wed, 29 Oct 2025 03:25:07 +0900 Subject: [PATCH 019/164] =?UTF-8?q?docs:=201round.md=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/1round.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/1round.md b/docs/1round.md index 922b0bcea..28d422eb9 100644 --- a/docs/1round.md +++ b/docs/1round.md @@ -7,9 +7,9 @@ **๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [x] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [x] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [x] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. **๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** From f1d94ed833be33a3c1125a406946a878084c67cf Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Wed, 29 Oct 2025 03:25:35 +0900 Subject: [PATCH 020/164] =?UTF-8?q?feat:=20UserRepository,=20UserService?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/user/UserRepository.java | 10 ++++++++ .../com/loopers/domain/user/UserService.java | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..6ac3b7b98 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + Optional findByUserId(String id); + + User save(User user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 000000000..b5dad2ef6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,25 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Transactional + User register(String userId, String email, String birth) { + userRepository.findByUserId(userId).ifPresent(user -> { + throw new CoreException(ErrorType.CONFLICT); + }); + + User user = new User(userId, email, birth); + return userRepository.save(user); + } + +} From a51021cf7e2c345d1e13aee7fc29402d9b885a32 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Wed, 29 Oct 2025 03:26:47 +0900 Subject: [PATCH 021/164] =?UTF-8?q?test:=20=ED=9A=8C=EC=9B=90=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=ED=86=B5=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/UserServiceIntegrationTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..f226daf6c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,52 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ") + @Nested + class UserRegister { + + @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") + @Test + void save_whenUserRegister() { + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String brith = "1994-12-05"; + + User user = new User(userId, email, brith); + UserRepository userRepositorySpy = spy(userRepository); + UserService userServiceSpy = new UserService(userRepositorySpy); + userServiceSpy.register(userId, email, brith); + + verify(userRepositorySpy).save(user); + } + } +} From a946ac0a78d4bd7cbe562f61fc3f81baf7dc1673 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Wed, 29 Oct 2025 03:32:17 +0900 Subject: [PATCH 022/164] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20import=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/user/UserRepository.java | 5 ++++- .../src/main/java/com/loopers/domain/user/UserService.java | 4 ++-- .../com/loopers/domain/user/UserServiceIntegrationTest.java | 2 -- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 6ac3b7b98..06234b87f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -1,10 +1,13 @@ package com.loopers.domain.user; +import org.springframework.stereotype.Component; + import java.util.Optional; +@Component public interface UserRepository { Optional findByUserId(String id); - User save(User user); + void save(User user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index b5dad2ef6..8603ad673 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -13,13 +13,13 @@ public class UserService { private final UserRepository userRepository; @Transactional - User register(String userId, String email, String birth) { + public void register(String userId, String email, String birth) { userRepository.findByUserId(userId).ifPresent(user -> { throw new CoreException(ErrorType.CONFLICT); }); User user = new User(userId, email, birth); - return userRepository.save(user); + userRepository.save(user); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index f226daf6c..a8b887ef2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -1,6 +1,5 @@ package com.loopers.domain.user; -import com.loopers.support.error.CoreException; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -9,7 +8,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; From 33c0ab862b18e90eabdbf0384325884d0b727b93 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 30 Oct 2025 04:43:19 +0900 Subject: [PATCH 023/164] =?UTF-8?q?test:=20=EB=82=B4=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 25 ++++ .../loopers/application/user/UserInfo.java | 14 ++ .../java/com/loopers/domain/user/User.java | 22 ++- .../loopers/domain/user/UserRepository.java | 7 +- .../com/loopers/domain/user/UserService.java | 13 +- .../user/UserJpaRepository.java | 10 ++ .../user/UserRepositoryImpl.java | 27 ++++ .../interfaces/api/user/UserV1ApiSpec.java | 28 ++++ .../interfaces/api/user/UserV1Controller.java | 31 ++++ .../interfaces/api/user/UserV1Dto.java | 24 ++++ .../user/UserServiceIntegrationTest.java | 29 ++-- .../api/user/UserV1ControllerTest.java | 135 ++++++++++++++++++ 12 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..4df3eff79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,25 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo register(String userId, String email, String birth, String gender) { + User user = userService.register(userId, email, birth, gender); + return UserInfo.from(user); + } + + public UserInfo getUser(String userId) { + return userService.findUserByUserId(userId) + .map(UserInfo::from) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํšŒ์› ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 000000000..08f5cea43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo(String userId, String email, String birth, String gender) { + public static UserInfo from(User user) { + return new UserInfo( + user.getUserId(), + user.getEmail(), + user.getBirth(), + user.getGender() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index fec224c80..67ce14001 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,31 +1,44 @@ package com.loopers.domain.user; import com.loopers.domain.BaseEntity; +import com.loopers.domain.point.Point; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.Getter; import java.util.regex.Pattern; @Entity @Table(name = "user") +@Getter public class User extends BaseEntity { private static final Pattern USERID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); private static final Pattern BIRTH_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); + @Column(unique = true, nullable = false) private String userId; + + @Column(nullable = false) private String email; + + @Column(nullable = false) private String birth; + @Column(nullable = false) + private String gender; + protected User() {} - public User(String userId, String email, String birth) { + public User(String userId, String email, String birth, String gender) { this.userId = requireValidUserId(userId); this.email = requireValidEmail(email); this.birth = requireValidBirthDate(birth); + this.gender = requireValidGender(gender); } String requireValidUserId(String userId) { @@ -60,4 +73,11 @@ String requireValidBirthDate(String birth) { } return birth; } + + String requireValidGender(String gender) { + if(gender == null || gender.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ฑ๋ณ„์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + return gender; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 06234b87f..f4b26266e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -1,13 +1,10 @@ package com.loopers.domain.user; -import org.springframework.stereotype.Component; - import java.util.Optional; -@Component public interface UserRepository { - Optional findByUserId(String id); + Optional findByUserId(String userId); - void save(User user); + User save(User user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 8603ad673..8dcf999e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -6,6 +6,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Component @RequiredArgsConstructor public class UserService { @@ -13,13 +15,18 @@ public class UserService { private final UserRepository userRepository; @Transactional - public void register(String userId, String email, String birth) { + public User register(String userId, String email, String birth, String gender) { userRepository.findByUserId(userId).ifPresent(user -> { throw new CoreException(ErrorType.CONFLICT); }); - User user = new User(userId, email, birth); - userRepository.save(user); + User user = new User(userId, email, birth, gender); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public Optional findUserByUserId(String userId){ + return userRepository.findByUserId(userId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..1a78b9a69 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..25f05bc6e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,27 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findByUserId(String userId) { + return userJpaRepository.findByUserId(userId); + } + + @Override + public User save(User user) { + userJpaRepository.save(user); + return user; + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 000000000..f11f386e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Users V1 API", description = "Users API์ž…๋‹ˆ๋‹ค.") +public interface UserV1ApiSpec { + + @Operation( + summary = "ํšŒ์› ๊ฐ€์ž…", + description = "ํšŒ์› ๊ฐ€์ž…์„ ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse register( + @Schema(description = "ํšŒ์›๊ฐ€์ž…") + UserV1Dto.RegisterRequest request + ); + + @Operation( + summary = "ํšŒ์› ์กฐํšŒ", + description = "ํ•ด๋‹น ํšŒ์›์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getUser( + @Schema(name = "ํšŒ์› ID", description = "์กฐํšŒํ•  ํšŒ์› ID") + String userId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 000000000..aed39ae1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @Override + @PostMapping("/register") + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo userInfo = userFacade.register(request.userId(), request.mail(), request.birth(), request.gender()); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); + return ApiResponse.success(response); + } + + @Override + @GetMapping("/{userId}") + public ApiResponse getUser(@PathVariable String userId) { + UserInfo userInfo = userFacade.getUser(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 000000000..cc634be67 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.Valid; + +public class UserV1Dto { + public record RegisterRequest( + String userId, + String mail, + String birth, + String gender + ){} + + public record UserResponse(String userId, String email, String birth, String gender) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.userId(), + info.email(), + info.birth(), + info.gender() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index a8b887ef2..edcfca9a9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -1,13 +1,12 @@ package com.loopers.domain.user; +import com.loopers.support.error.CoreException; import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -28,7 +27,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ") + @DisplayName("ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") @Nested class UserRegister { @@ -38,13 +37,27 @@ void save_whenUserRegister() { String userId = "yh45g"; String email = "yh45g@loopers.com"; String brith = "1994-12-05"; + String gender = "Male"; - User user = new User(userId, email, brith); UserRepository userRepositorySpy = spy(userRepository); UserService userServiceSpy = new UserService(userRepositorySpy); - userServiceSpy.register(userId, email, brith); + userServiceSpy.register(userId, email, brith, gender); - verify(userRepositorySpy).save(user); + verify(userRepositorySpy).save(any(User.class)); + } + + @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") + @Test + void register_whenUserIdAlreadyExists_thenFail() { + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String brith = "1994-12-05"; + String gender = "Male"; + + userService.register(userId, email, brith, gender); + + Assertions.assertThrows(CoreException.class, () + -> userService.register(userId, email, brith, gender)); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java new file mode 100644 index 000000000..c0648f86c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java @@ -0,0 +1,135 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ControllerTest { + + private static final String REGISTER_ENDPOINT = "/api/v1/users/register"; + private static final Function GETUSER_ENDPOINT = id -> "/api/v1/users/" + id; + private final TestRestTemplate testRestTemplate; + private final UserJpaRepository userJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ControllerTest(TestRestTemplate testRestTemplate, UserJpaRepository userJpaRepository, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.userJpaRepository = userJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class RegisterUser { + @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void registerUser_whenSuccessResponseUser() { + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().email()).isEqualTo(email), + () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), + () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) + ); + } + @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenGenderIsNotProvided() { + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = null; + + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); + + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("GET /api/v1/users/{userId}") + @Nested + class GetUserById { + @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void getUserById_whenSuccessResponseUser() { + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + userJpaRepository.save(new User(userId, email, birth, gender)); + + String requestUrl = GETUSER_ENDPOINT.apply(userId); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().email()).isEqualTo(email), + () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), + () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) + ); + } + + @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void throwsException_whenInvalidUserIdIsProvided() { + String userId = "notUserId"; + String requestUrl = GETUSER_ENDPOINT.apply(userId); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + HttpHeaders headers = new HttpHeaders(); + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } + +} From b73c2c6d549816528d3c5165e7e200ef34a4d95f Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 30 Oct 2025 04:43:49 +0900 Subject: [PATCH 024/164] docs: 1round.md check list update --- docs/1round.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/1round.md b/docs/1round.md index 28d422eb9..b8931c23b 100644 --- a/docs/1round.md +++ b/docs/1round.md @@ -13,25 +13,25 @@ **๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. +- [x] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) +- [x] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. **๐ŸŒ E2E ํ…Œ์ŠคํŠธ** -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ### ๋‚ด ์ •๋ณด ์กฐํšŒ **๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. **๐ŸŒ E2E ํ…Œ์ŠคํŠธ** -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ### ํฌ์ธํŠธ ์กฐํšŒ From 8d5e90a8a0129ead9315ac4bbb80b1560fc68d45 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 30 Oct 2025 04:44:08 +0900 Subject: [PATCH 025/164] =?UTF-8?q?feat:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=8F=84=EB=A9=94=EC=9D=B8=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 --- .../java/com/loopers/domain/point/Point.java | 20 +++++++++++++++++++ .../loopers/domain/point/PointRepository.java | 4 ++++ .../loopers/domain/point/PointService.java | 6 ++++++ .../point/PointJpaRepository.java | 7 +++++++ 4 files changed, 37 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java new file mode 100644 index 000000000..26266d141 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -0,0 +1,20 @@ +package com.loopers.domain.point; + + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.User; +import jakarta.persistence.*; + +@Entity +@Table(name = "point") +public class Point extends BaseEntity { + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", unique = true, nullable = false) + private User user; + + @Column(nullable = false) + private Long Amount; + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java new file mode 100644 index 000000000..0a50068ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -0,0 +1,4 @@ +package com.loopers.domain.point; + +public interface PointRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java new file mode 100644 index 000000000..03807f888 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,6 @@ +package com.loopers.domain.point; + +public class PointService { + + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java new file mode 100644 index 000000000..aa1089a9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.Point; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PointJpaRepository extends JpaRepository { +} From d6711cfa75f8019e9555edb24d390d1af0f34d4b Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 30 Oct 2025 04:46:37 +0900 Subject: [PATCH 026/164] =?UTF-8?q?feat:=20=EB=82=B4=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20E2E=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/User.java | 1 - .../java/com/loopers/interfaces/api/user/UserV1ApiSpec.java | 2 +- .../main/java/com/loopers/interfaces/api/user/UserV1Dto.java | 1 - .../com/loopers/interfaces/api/user/UserV1ControllerTest.java | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 67ce14001..287b84cf8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,7 +1,6 @@ package com.loopers.domain.user; import com.loopers.domain.BaseEntity; -import com.loopers.domain.point.Point; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index f11f386e2..dec948cdf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "Users V1 API", description = "Users API์ž…๋‹ˆ๋‹ค.") +@Tag(name = "Users V1 API", description = "Users API ์ž…๋‹ˆ๋‹ค.") public interface UserV1ApiSpec { @Operation( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index cc634be67..16aab05e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,7 +1,6 @@ package com.loopers.interfaces.api.user; import com.loopers.application.user.UserInfo; -import jakarta.validation.Valid; public class UserV1Dto { public record RegisterRequest( diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java index c0648f86c..5a297152b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java @@ -124,7 +124,6 @@ void throwsException_whenInvalidUserIdIsProvided() { ResponseEntity> response = testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - HttpHeaders headers = new HttpHeaders(); assertAll( () -> assertTrue(response.getStatusCode().is4xxClientError()), () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) From 7a566848790f056205df2f593f7d90324e48cb79 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:02:34 +0900 Subject: [PATCH 027/164] =?UTF-8?q?docs:=201round.md=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/1round.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/1round.md b/docs/1round.md index b8931c23b..106d6c809 100644 --- a/docs/1round.md +++ b/docs/1round.md @@ -37,31 +37,31 @@ **๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. **๐ŸŒ E2E ํ…Œ์ŠคํŠธ** -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ### ํฌ์ธํŠธ ์ถฉ์ „ **๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** -- [ ] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. +- [X] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. **๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. +- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. **๐ŸŒ E2E ํ…Œ์ŠคํŠธ** -- [ ] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [X] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. ## โœ… Checklist -- [ ] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ -- [ ] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ -- [ ] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ \ No newline at end of file +- [X] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ +- [X] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ +- [X] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ \ No newline at end of file From ded9e381c05c545f59c24e8e9f0f4d90f7f4d980 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:06:36 +0900 Subject: [PATCH 028/164] =?UTF-8?q?chore:=20=ED=8C=8C=EC=9D=BC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/{ => example}/ExampleV1ApiE2ETest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => example}/ExampleV1ApiE2ETest.java (98%) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java index 1bb3dba65..70f256149 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.example; import com.loopers.domain.example.ExampleModel; import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; +import com.loopers.interfaces.api.ApiResponse; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; From 5b83c0381587841ba3e14015d9233013c6d37122 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:07:19 +0900 Subject: [PATCH 029/164] =?UTF-8?q?feat:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=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 --- .../application/point/PointFacade.java | 28 ++++++++++++++ .../loopers/application/point/PointInfo.java | 13 +++++++ .../java/com/loopers/domain/point/Point.java | 37 +++++++++++++++---- .../loopers/domain/point/PointRepository.java | 6 +++ .../loopers/domain/point/PointService.java | 24 ++++++++++++ .../point/PointJpaRepository.java | 4 ++ .../point/PointRepositoryImpl.java | 26 +++++++++++++ .../interfaces/api/point/PointV1ApiSpec.java | 27 ++++++++++++++ .../api/point/PointV1Controller.java | 31 ++++++++++++++++ .../interfaces/api/point/PointV1Dto.java | 17 +++++++++ 10 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java new file mode 100644 index 000000000..009be1cec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -0,0 +1,28 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointService; +import com.loopers.interfaces.api.point.PointV1Dto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PointFacade { + private final PointService pointService; + + public PointInfo getPoint(String userId) { + Point point = pointService.findPointByUserId(userId); + + if (point == null) { + throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return PointInfo.from(point); + } + + public PointInfo chargePoint(PointV1Dto.ChargePointRequest request) { + return PointInfo.from(pointService.chargePoint(request.userId(), request.chargeAmount())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java new file mode 100644 index 000000000..2c357dc7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.Point; + +public record PointInfo(String userId, Long amount) { + public static PointInfo from(Point info) { + return new PointInfo( + info.getUserId(), + info.getAmount() + ); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 26266d141..bef72fb00 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -1,20 +1,41 @@ package com.loopers.domain.point; - import com.loopers.domain.BaseEntity; -import com.loopers.domain.user.User; -import jakarta.persistence.*; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; @Entity @Table(name = "point") +@Getter public class Point extends BaseEntity { - @OneToOne(fetch = FetchType.LAZY, optional = false) - @JoinColumn(name = "user_id", unique = true, nullable = false) - private User user; + private String userId; + + private Long amount; + + protected Point() {} + + public Point(String userId, Long amount) { + this.userId = requireValidUserId(userId); + this.amount = amount; + } - @Column(nullable = false) - private Long Amount; + String requireValidUserId(String userId) { + if(userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return userId; + } + public Point charge(Long chargeAmount) { + if (chargeAmount == null || chargeAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + this.amount += chargeAmount; + return new Point(this.userId, this.amount); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java index 0a50068ca..314022491 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -1,4 +1,10 @@ package com.loopers.domain.point; +import java.util.Optional; + public interface PointRepository { + + Optional findByUserId(String userId); + + Point save(Point point); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 03807f888..2ecc7b456 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -1,6 +1,30 @@ package com.loopers.domain.point; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component public class PointService { + private final PointRepository pointRepository; + + + @Transactional(readOnly = true) + public Point findPointByUserId(String userId) { + return pointRepository.findByUserId(userId).orElse(null); + } + + @Transactional + public Point chargePoint(String userId, Long chargeAmount) { + Point point = pointRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ• ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + point.charge(chargeAmount); + return pointRepository.save(point); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java index aa1089a9e..a35a56151 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -3,5 +3,9 @@ import com.loopers.domain.point.Point; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface PointJpaRepository extends JpaRepository { + + Optional findByUserId(String userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java new file mode 100644 index 000000000..1663b1d4f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PointRepositoryImpl implements PointRepository { + + private final PointJpaRepository pointJpaRepository; + + @Override + public Optional findByUserId(String userId) { + return pointJpaRepository.findByUserId(userId); + } + + @Override + public Point save(Point point) { + pointJpaRepository.save(point); + return point; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java new file mode 100644 index 000000000..b954c1a4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +@Tag(name = "Point V1 API", description = "Point API ์ž…๋‹ˆ๋‹ค.") +public interface PointV1ApiSpec { + + @Operation( + summary = "ํฌ์ธํŠธ ํšŒ์› ์กฐํšŒ", + description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•œ๋‹ค." + ) + ApiResponse getPoint( + @Schema(name = "ํšŒ์› Id", description = "์กฐํšŒํ•  ํšŒ์› ID") + String userId + ); + + @Operation( + summary = "ํฌ์ธํŠธ ์ถฉ์ „", + description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•œ๋‹ค." + ) + ApiResponse chargePoint( + @Schema(name = "ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ", description = "์กฐํšŒํ•  ํšŒ์› ID") + PointV1Dto.ChargePointRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..866fce9b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointFacade; +import com.loopers.application.point.PointInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/points") +public class PointV1Controller implements PointV1ApiSpec { + + private final PointFacade pointFacade; + + @Override + @GetMapping + public ApiResponse getPoint(@RequestHeader("X-USER-ID") String userId) { + PointInfo pointInfo = pointFacade.getPoint(userId); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); + return ApiResponse.success(response); + } + + @Override + @PatchMapping("/charge") + public ApiResponse chargePoint(@RequestBody PointV1Dto.ChargePointRequest request) { + PointInfo pointInfo = pointFacade.chargePoint(request); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 000000000..406e84cc5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointInfo; + +public class PointV1Dto { + + public record ChargePointRequest(String userId, Long chargeAmount) {} + + public record PointResponse(String userId, Long amount){ + public static PointResponse from(PointInfo info) { + return new PointResponse( + info.userId(), + info.amount() + ); + } + } +} From 3158c2bce49c3e9a6cea90cc499c54688ecb169a Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:07:37 +0900 Subject: [PATCH 030/164] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=8B=A8=EC=9C=84=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/point/PointTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java new file mode 100644 index 000000000..bcc911321 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java @@ -0,0 +1,23 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PointTest { + @DisplayName("Point ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") + @Nested + class Charge { + @DisplayName("0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsChargeAmountFailException_whenZeroAmountOrNegative() { + Point point = new Point("yh45g", 0L); + assertThrows(CoreException.class, () -> + point.charge(0L)); + } + } + +} From e287600296c6797ca187820d7036f229713cc969 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:07:42 +0900 Subject: [PATCH 031/164] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=86=B5=ED=95=A9=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/PointServiceIntegrationTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java new file mode 100644 index 000000000..544db751b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -0,0 +1,86 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class PointServiceIntegrationTest { + + @Autowired + private PointRepository pointRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PointService pointService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class PointUser { + + @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnPointInfo_whenValidIdIsProvided() { + String id = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + userRepository.save(new User(id, email, birth, gender)); + pointRepository.save(new Point(id, 0L)); + + Point result = pointService.findPointByUserId(id); + + assertThat(result.getUserId()).isEqualTo(id); + assertThat(result.getAmount()).isEqualTo(0L); + } + + @DisplayName("ํšŒ์›์ด ์กด์žฌ ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnNull_whenInvalidUserIdIsProvided() { + String id = "yh45g"; + + Point point = pointService.findPointByUserId(id); + + assertThat(point).isNull(); + } + } + + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class Charge { + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsChargeAmountFailException_whenUserIDIsNotProvided() { + String id = "yh45g"; + + CoreException exception = assertThrows(CoreException.class, () -> { + pointService.chargePoint(id, 1000L); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} From 9c5e6ea939ed49903969a5743b4c79cfe55e5df8 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:07:49 +0900 Subject: [PATCH 032/164] =?UTF-8?q?test:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20E2E=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/point/PointV1ControllerTest.java | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java new file mode 100644 index 000000000..8e8507f72 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java @@ -0,0 +1,140 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PointV1ControllerTest { + + private static final String GET_USER_POINT_ENDPOINT = "/api/v1/points"; + private static final String POST_USER_POINT_ENDPOINT = "/api/v1/points/charge"; + + @Autowired + private PointRepository pointRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private TestRestTemplate testRestTemplate; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/points") + @Nested + class UserPoint { + + @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnPoint_whenValidUserIdIsProvided() { + String id = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + Long amount = 1000L; + + userRepository.save(new User(id, email, birth, gender)); + pointRepository.save(new Point(id, amount)); + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(id), + () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) + ); + } + + @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNull_whenUserIdExists() { + String id = "yh45g"; + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getBody().data()).isNull() + ); + } + } + + @DisplayName("POST /api/v1/points/charge") + @Nested + class Charge { + + @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTotalPoint_whenChargeUserPoint() { + String id = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + userRepository.save(new User(id, email, birth, gender)); + pointRepository.save(new Point(id, 0L)); + + PointV1Dto.ChargePointRequest request = new PointV1Dto.ChargePointRequest(id, 1000L); + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(id), + () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidUserIdIsProvided() { + String id = "yh45g"; + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} From 3774002312fc9fddcc417b938e19c46731f1591a Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:08:22 +0900 Subject: [PATCH 033/164] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/user/UserFacade.java | 8 ++- .../com/loopers/domain/user/UserService.java | 6 +- .../user/UserJpaRepository.java | 1 + .../interfaces/api/user/UserV1ApiSpec.java | 2 +- .../user/UserServiceIntegrationTest.java | 40 +++++++++++- .../com/loopers/domain/user/UserTest.java | 61 +++++++++++++++---- .../api/user/UserV1ControllerTest.java | 37 +++++------ 7 files changed, 114 insertions(+), 41 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 4df3eff79..f42bd5206 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -18,8 +18,10 @@ public UserInfo register(String userId, String email, String birth, String gende } public UserInfo getUser(String userId) { - return userService.findUserByUserId(userId) - .map(UserInfo::from) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํšŒ์› ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + User user = userService.findUserByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return UserInfo.from(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 8dcf999e8..db386e1e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -6,8 +6,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - @Component @RequiredArgsConstructor public class UserService { @@ -25,8 +23,8 @@ public User register(String userId, String email, String birth, String gender) { } @Transactional(readOnly = true) - public Optional findUserByUserId(String userId){ - return userRepository.findByUserId(userId); + public User findUserByUserId(String userId){ + return userRepository.findByUserId(userId).orElse(null); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 1a78b9a69..f80a5bc52 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -6,5 +6,6 @@ import java.util.Optional; public interface UserJpaRepository extends JpaRepository { + Optional findByUserId(String userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index dec948cdf..235e8e9fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -13,7 +13,7 @@ public interface UserV1ApiSpec { description = "ํšŒ์› ๊ฐ€์ž…์„ ํ•ฉ๋‹ˆ๋‹ค." ) ApiResponse register( - @Schema(description = "ํšŒ์›๊ฐ€์ž…") + @Schema(name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", description = "ํšŒ์›๊ฐ€์ž…") UserV1Dto.RegisterRequest request ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index edcfca9a9..c342c8aa7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; @@ -27,7 +29,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @DisplayName("ํšŒ์› ๊ฐ€์ž… ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") @Nested class UserRegister { @@ -48,7 +50,7 @@ void save_whenUserRegister() { @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") @Test - void register_whenUserIdAlreadyExists_thenFail() { + void throwsException_whenDuplicateUserId() { String userId = "yh45g"; String email = "yh45g@loopers.com"; String brith = "1994-12-05"; @@ -60,4 +62,38 @@ void register_whenUserIdAlreadyExists_thenFail() { -> userService.register(userId, email, brith, gender)); } } + + @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class Get { + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsUser_whenValidIdIsProvided() { + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String brith = "1994-12-05"; + String gender = "Male"; + + userService.register(userId, email, brith, gender); + + User user = userService.findUserByUserId(userId); + + assertAll( + () -> assertThat(user.getUserId()).isEqualTo(userId), + () -> assertThat(user.getEmail()).isEqualTo(email), + () -> assertThat(user.getBirth()).isEqualTo(brith), + () -> assertThat(user.getGender()).isEqualTo(gender) + ); + } + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnNull_whenInvalidUserIdIsProvided() { + String userId = "yh45g"; + User user = userService.findUserByUserId(userId); + + assertThat(user).isNull(); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index 1d938accc..a8f8948ca 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -1,18 +1,53 @@ package com.loopers.domain.user; -import static org.junit.jupiter.api.Assertions.*; - -/** - * packageName : com.loopers.domain.user - * fileName : UserTest - * author : byeonsungmun - * date : 25. 10. 28. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 25. 10. 28. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + class UserTest { + @DisplayName("User ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") + @Nested + class Create { + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidUserIdFormat() { + // given + String invalidUserId = "invalid_id_123"; // 10์ž ์ดˆ๊ณผ + ํŠน์ˆ˜๋ฌธ์ž ํฌํ•จ + String email = "valid@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + // when & then + assertThrows(CoreException.class, () -> new User(invalidUserId, email, birth, gender)); + } + + @DisplayName("์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidEmailFormat() { + // given + String userId = "yh45g"; + String invalidEmail = "invalid-email-format"; // '@' ์—†์Œ + String birth = "1994-12-05"; + String gender = "MALE"; + + // when & then + assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidBirthFormat() { + // given + String userId = "yh45g"; + String invalidEmail = "invalid-email-format"; // '@' ์—†์Œ + String birth = "1994-12-05"; + String gender = "MALE"; + // when & then + assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java index 5a297152b..b99da8b4e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java @@ -12,7 +12,10 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.*; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import java.util.function.Function; @@ -23,18 +26,17 @@ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class UserV1ControllerTest { - private static final String REGISTER_ENDPOINT = "/api/v1/users/register"; - private static final Function GETUSER_ENDPOINT = id -> "/api/v1/users/" + id; - private final TestRestTemplate testRestTemplate; - private final UserJpaRepository userJpaRepository; - private final DatabaseCleanUp databaseCleanUp; + private static final String USER_REGISTER_ENDPOINT = "/api/v1/users/register"; + private static final Function GET_USER_ENDPOINT = id -> "/api/v1/users/" + id; @Autowired - public UserV1ControllerTest(TestRestTemplate testRestTemplate, UserJpaRepository userJpaRepository, DatabaseCleanUp databaseCleanUp) { - this.testRestTemplate = testRestTemplate; - this.userJpaRepository = userJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } + private TestRestTemplate testRestTemplate; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; @AfterEach void tearDown() { @@ -57,7 +59,7 @@ void registerUser_whenSuccessResponseUser() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), @@ -79,7 +81,7 @@ void throwsBadRequest_whenGenderIsNotProvided() { ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = - testRestTemplate.exchange(REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); assertAll( () -> assertTrue(response.getStatusCode().is4xxClientError()), @@ -91,7 +93,7 @@ void throwsBadRequest_whenGenderIsNotProvided() { @DisplayName("GET /api/v1/users/{userId}") @Nested class GetUserById { - @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void getUserById_whenSuccessResponseUser() { String userId = "yh45g"; @@ -101,7 +103,7 @@ void getUserById_whenSuccessResponseUser() { userJpaRepository.save(new User(userId, email, birth, gender)); - String requestUrl = GETUSER_ENDPOINT.apply(userId); + String requestUrl = GET_USER_ENDPOINT.apply(userId); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); @@ -115,11 +117,11 @@ void getUserById_whenSuccessResponseUser() { ); } - @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void throwsException_whenInvalidUserIdIsProvided() { String userId = "notUserId"; - String requestUrl = GETUSER_ENDPOINT.apply(userId); + String requestUrl = GET_USER_ENDPOINT.apply(userId); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); @@ -130,5 +132,4 @@ void throwsException_whenInvalidUserIdIsProvided() { ); } } - } From 8e5643aa0e3276c4028fb05abe30fa9108966f84 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 31 Oct 2025 05:10:30 +0900 Subject: [PATCH 034/164] =?UTF-8?q?chore:=20=ED=9A=8C=EC=9B=90=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/point/Point.java | 4 ++-- .../com/loopers/domain/point/PointServiceIntegrationTest.java | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index bef72fb00..02e1f9f7f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -30,12 +30,12 @@ String requireValidUserId(String userId) { return userId; } - public Point charge(Long chargeAmount) { + public void charge(Long chargeAmount) { if (chargeAmount == null || chargeAmount <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } this.amount += chargeAmount; - return new Point(this.userId, this.amount); + new Point(this.userId, this.amount); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index 544db751b..2ba880463 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -76,9 +76,7 @@ class Charge { void throwsChargeAmountFailException_whenUserIDIsNotProvided() { String id = "yh45g"; - CoreException exception = assertThrows(CoreException.class, () -> { - pointService.chargePoint(id, 1000L); - }); + CoreException exception = assertThrows(CoreException.class, () -> pointService.chargePoint(id, 1000L)); assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } From ef3eebdce67a2a6566c5636de732acb9778095fd Mon Sep 17 00:00:00 2001 From: bookers-web Date: Fri, 31 Oct 2025 15:26:04 +0900 Subject: [PATCH 035/164] =?UTF-8?q?style:=20=EC=BD=94=EB=93=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/point/Point.java | 1 - .../main/java/com/loopers/domain/point/PointService.java | 6 +----- .../main/java/com/loopers/domain/user/UserService.java | 2 +- .../com/loopers/interfaces/api/point/PointV1ApiSpec.java | 1 + .../java/com/loopers/interfaces/api/point/PointV1Dto.java | 5 +++-- .../com/loopers/interfaces/api/user/UserV1ApiSpec.java | 8 ++++---- .../java/com/loopers/interfaces/api/user/UserV1Dto.java | 3 ++- 7 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 02e1f9f7f..b8f453f14 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -37,5 +37,4 @@ public void charge(Long chargeAmount) { this.amount += chargeAmount; new Point(this.userId, this.amount); } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 2ecc7b456..dfc0788c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -12,7 +12,6 @@ public class PointService { private final PointRepository pointRepository; - @Transactional(readOnly = true) public Point findPointByUserId(String userId) { return pointRepository.findByUserId(userId).orElse(null); @@ -20,11 +19,8 @@ public Point findPointByUserId(String userId) { @Transactional public Point chargePoint(String userId, Long chargeAmount) { - Point point = pointRepository.findByUserId(userId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ• ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + Point point = pointRepository.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ• ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); point.charge(chargeAmount); return pointRepository.save(point); } - - } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index db386e1e8..da2878300 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -23,7 +23,7 @@ public User register(String userId, String email, String birth, String gender) { } @Transactional(readOnly = true) - public User findUserByUserId(String userId){ + public User findUserByUserId(String userId) { return userRepository.findByUserId(userId).orElse(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java index b954c1a4e..faa21f303 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; + @Tag(name = "Point V1 API", description = "Point API ์ž…๋‹ˆ๋‹ค.") public interface PointV1ApiSpec { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java index 406e84cc5..b0b3d050e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -4,9 +4,10 @@ public class PointV1Dto { - public record ChargePointRequest(String userId, Long chargeAmount) {} + public record ChargePointRequest(String userId, Long chargeAmount) { + } - public record PointResponse(String userId, Long amount){ + public record PointResponse(String userId, Long amount) { public static PointResponse from(PointInfo info) { return new PointResponse( info.userId(), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index 235e8e9fa..1bed68e62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -13,8 +13,8 @@ public interface UserV1ApiSpec { description = "ํšŒ์› ๊ฐ€์ž…์„ ํ•ฉ๋‹ˆ๋‹ค." ) ApiResponse register( - @Schema(name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", description = "ํšŒ์›๊ฐ€์ž…") - UserV1Dto.RegisterRequest request + @Schema(name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", description = "ํšŒ์›๊ฐ€์ž…") + UserV1Dto.RegisterRequest request ); @Operation( @@ -22,7 +22,7 @@ ApiResponse register( description = "ํ•ด๋‹น ํšŒ์›์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." ) ApiResponse getUser( - @Schema(name = "ํšŒ์› ID", description = "์กฐํšŒํ•  ํšŒ์› ID") - String userId + @Schema(name = "ํšŒ์› ID", description = "์กฐํšŒํ•  ํšŒ์› ID") + String userId ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 16aab05e2..263214848 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -8,7 +8,8 @@ public record RegisterRequest( String mail, String birth, String gender - ){} + ) { + } public record UserResponse(String userId, String email, String birth, String gender) { public static UserResponse from(UserInfo info) { From d7386544e6f8468272cd5be72e14007ee2eaa32e Mon Sep 17 00:00:00 2001 From: bookers-web Date: Fri, 31 Oct 2025 15:46:55 +0900 Subject: [PATCH 036/164] =?UTF-8?q?remove:=20sample=20code=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/example/ExampleFacade.java | 17 --- .../application/example/ExampleInfo.java | 13 -- .../loopers/domain/example/ExampleModel.java | 44 ------- .../domain/example/ExampleRepository.java | 7 -- .../domain/example/ExampleService.java | 20 --- .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 --- .../api/example/ExampleV1ApiSpec.java | 19 --- .../api/example/ExampleV1Controller.java | 28 ----- .../interfaces/api/example/ExampleV1Dto.java | 15 --- .../domain/example/ExampleModelTest.java | 65 ---------- .../ExampleServiceIntegrationTest.java | 72 ----------- .../api/example/ExampleV1ApiE2ETest.java | 114 ------------------ 13 files changed, 439 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] ์˜ˆ์‹œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers ์˜ˆ์‹œ API ์ž…๋‹ˆ๋‹ค.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "์˜ˆ์‹œ ์กฐํšŒ", - description = "ID๋กœ ์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getExample( - @Schema(name = "์˜ˆ์‹œ ID", description = "์กฐํšŒํ•  ์˜ˆ์‹œ์˜ ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("์˜ˆ์‹œ ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "์ œ๋ชฉ"; - String description = "์„ค๋ช…"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("์ œ๋ชฉ์ด ๋นˆ์นธ์œผ๋กœ๋งŒ ์ด๋ฃจ์–ด์ ธ ์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "์„ค๋ช…"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("์ œ๋ชฉ", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") - @Nested - class Get { - @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java deleted file mode 100644 index 70f256149..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/example/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID ๋กœ ์š”์ฒญํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/๋‚˜๋‚˜"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, 404 NOT_FOUND ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} From b06e034f2c369f6c3c5c7010e18449a2b59cc950 Mon Sep 17 00:00:00 2001 From: bookers-web Date: Fri, 31 Oct 2025 15:47:43 +0900 Subject: [PATCH 037/164] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20given=20when=20then=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../point/PointServiceIntegrationTest.java | 9 +++++++++ .../java/com/loopers/domain/point/PointTest.java | 3 +++ .../domain/user/UserServiceIntegrationTest.java | 15 ++++++++++++++- .../api/point/PointV1ControllerTest.java | 16 ++++++++++++++++ .../api/user/UserV1ControllerTest.java | 15 ++++++++++++++- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index 2ba880463..882bca673 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -42,6 +42,7 @@ class PointUser { @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test void returnPointInfo_whenValidIdIsProvided() { + //given String id = "yh45g"; String email = "yh45g@loopers.com"; String birth = "1994-12-05"; @@ -50,8 +51,10 @@ void returnPointInfo_whenValidIdIsProvided() { userRepository.save(new User(id, email, birth, gender)); pointRepository.save(new Point(id, 0L)); + //when Point result = pointService.findPointByUserId(id); + //then assertThat(result.getUserId()).isEqualTo(id); assertThat(result.getAmount()).isEqualTo(0L); } @@ -59,10 +62,13 @@ void returnPointInfo_whenValidIdIsProvided() { @DisplayName("ํšŒ์›์ด ์กด์žฌ ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test void returnNull_whenInvalidUserIdIsProvided() { + //given String id = "yh45g"; + //when Point point = pointService.findPointByUserId(id); + //then assertThat(point).isNull(); } } @@ -74,10 +80,13 @@ class Charge { @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") @Test void throwsChargeAmountFailException_whenUserIDIsNotProvided() { + //given String id = "yh45g"; + //when CoreException exception = assertThrows(CoreException.class, () -> pointService.chargePoint(id, 1000L)); + //then assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java index bcc911321..81bedab7d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java @@ -14,7 +14,10 @@ class Charge { @DisplayName("0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค.") @Test void throwsChargeAmountFailException_whenZeroAmountOrNegative() { + //given Point point = new Point("yh45g", 0L); + + //when&then assertThrows(CoreException.class, () -> point.charge(0L)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index c342c8aa7..71091883f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -36,6 +36,7 @@ class UserRegister { @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") @Test void save_whenUserRegister() { + //given String userId = "yh45g"; String email = "yh45g@loopers.com"; String brith = "1994-12-05"; @@ -43,21 +44,27 @@ void save_whenUserRegister() { UserRepository userRepositorySpy = spy(userRepository); UserService userServiceSpy = new UserService(userRepositorySpy); + + //when userServiceSpy.register(userId, email, brith, gender); + //then verify(userRepositorySpy).save(any(User.class)); } @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") @Test void throwsException_whenDuplicateUserId() { + //given String userId = "yh45g"; String email = "yh45g@loopers.com"; String brith = "1994-12-05"; String gender = "Male"; + //when userService.register(userId, email, brith, gender); + //then Assertions.assertThrows(CoreException.class, () -> userService.register(userId, email, brith, gender)); } @@ -70,15 +77,17 @@ class Get { @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test void returnsUser_whenValidIdIsProvided() { + //given String userId = "yh45g"; String email = "yh45g@loopers.com"; String brith = "1994-12-05"; String gender = "Male"; + //when userService.register(userId, email, brith, gender); - User user = userService.findUserByUserId(userId); + //then assertAll( () -> assertThat(user.getUserId()).isEqualTo(userId), () -> assertThat(user.getEmail()).isEqualTo(email), @@ -90,9 +99,13 @@ void returnsUser_whenValidIdIsProvided() { @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test void returnNull_whenInvalidUserIdIsProvided() { + //given String userId = "yh45g"; + + //when User user = userService.findUserByUserId(userId); + //then assertThat(user).isNull(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java index 8e8507f72..b725fd807 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java @@ -50,6 +50,7 @@ class UserPoint { @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnPoint_whenValidUserIdIsProvided() { + //given String id = "yh45g"; String email = "yh45g@loopers.com"; String birth = "1994-12-05"; @@ -61,10 +62,13 @@ void returnPoint_whenValidUserIdIsProvided() { HttpHeaders headers = new HttpHeaders(); headers.add("X-USER-ID", id); + + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody().data().userId()).isEqualTo(id), @@ -75,14 +79,18 @@ void returnPoint_whenValidUserIdIsProvided() { @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnNull_whenUserIdExists() { + //given String id = "yh45g"; HttpHeaders headers = new HttpHeaders(); headers.add("X-USER-ID", id); + + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is4xxClientError()), () -> assertThat(response.getBody().data()).isNull() @@ -97,6 +105,7 @@ class Charge { @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnsTotalPoint_whenChargeUserPoint() { + //given String id = "yh45g"; String email = "yh45g@loopers.com"; String birth = "1994-12-05"; @@ -109,10 +118,13 @@ void returnsTotalPoint_whenChargeUserPoint() { HttpHeaders headers = new HttpHeaders(); headers.add("X-USER-ID", id); + + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody().data().userId()).isEqualTo(id), @@ -123,14 +135,18 @@ void returnsTotalPoint_whenChargeUserPoint() { @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void throwsException_whenInvalidUserIdIsProvided() { + //given String id = "yh45g"; HttpHeaders headers = new HttpHeaders(); headers.add("X-USER-ID", id); + + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(null, headers), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is4xxClientError()), () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java index b99da8b4e..defe2fcd5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java @@ -49,6 +49,7 @@ class RegisterUser { @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void registerUser_whenSuccessResponseUser() { + //given String userId = "yh45g"; String email = "yh45g@loopers.com"; String birth = "1994-12-05"; @@ -56,11 +57,12 @@ void registerUser_whenSuccessResponseUser() { UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), @@ -72,6 +74,7 @@ void registerUser_whenSuccessResponseUser() { @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") @Test void throwsBadRequest_whenGenderIsNotProvided() { + //given String userId = "yh45g"; String email = "yh45g@loopers.com"; String birth = "1994-12-05"; @@ -79,10 +82,12 @@ void throwsBadRequest_whenGenderIsNotProvided() { UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is4xxClientError()), () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) @@ -96,6 +101,7 @@ class GetUserById { @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void getUserById_whenSuccessResponseUser() { + //given String userId = "yh45g"; String email = "yh45g@loopers.com"; String birth = "1994-12-05"; @@ -104,10 +110,13 @@ void getUserById_whenSuccessResponseUser() { userJpaRepository.save(new User(userId, email, birth, gender)); String requestUrl = GET_USER_ENDPOINT.apply(userId); + + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), @@ -120,12 +129,16 @@ void getUserById_whenSuccessResponseUser() { @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void throwsException_whenInvalidUserIdIsProvided() { + //given String userId = "notUserId"; String requestUrl = GET_USER_ENDPOINT.apply(userId); + + //when ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; ResponseEntity> response = testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + //then assertAll( () -> assertTrue(response.getStatusCode().is4xxClientError()), () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) From 6c487552bdc209e74b762ab8600898c3df61cc78 Mon Sep 17 00:00:00 2001 From: bookers-web Date: Fri, 31 Oct 2025 16:46:40 +0900 Subject: [PATCH 038/164] =?UTF-8?q?fix:=20Exception=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/user/UserService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index da2878300..57353968a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -15,7 +15,7 @@ public class UserService { @Transactional public User register(String userId, String email, String birth, String gender) { userRepository.findByUserId(userId).ifPresent(user -> { - throw new CoreException(ErrorType.CONFLICT); + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์žID ์ž…๋‹ˆ๋‹ค."); }); User user = new User(userId, email, birth, gender); From d9ae7adc3cfd0fb44a493c8638e258ce188e8750 Mon Sep 17 00:00:00 2001 From: simplify-len Date: Mon, 3 Nov 2025 22:08:41 +0900 Subject: [PATCH 039/164] Add GitHub Actions workflow for PR Agent --- .github/workflows/main.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..1f80db6bf --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,13 @@ +name: PR Agent +on: + pull_request: + types: [opened, synchronize] +jobs: + pr_agent_job: + runs-on: ubuntu-latest + steps: + - name: PR Agent action step + uses: Codium-ai/pr-agent@main + env: + OPENAI_KEY: ${{ secrets.OPENAI_KEY }} + GITHUB_TOKEN: ${{ secrets.G_TOKEN }} From 5bb8d33f7ffcc3e4d48c6b744ca8e4716629c9e7 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 7 Nov 2025 16:58:30 +0900 Subject: [PATCH 040/164] =?UTF-8?q?docs=20:=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=A0=95=EC=9D=98,=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=AA=85=EC=84=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2round/01-requirements.md | 104 +++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/2round/01-requirements.md diff --git a/docs/2round/01-requirements.md b/docs/2round/01-requirements.md new file mode 100644 index 000000000..3296c21c6 --- /dev/null +++ b/docs/2round/01-requirements.md @@ -0,0 +1,104 @@ +# ์œ ์ €-์‹œ๋‚˜๋ฆฌ์˜ค + +## ์ƒํ’ˆ ๋ชฉ๋ก +1. ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋“  ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ๋ณผ์ˆ˜ ์žˆ๋‹ค. +2. ํŒ๋งค์ค‘์ธ ์ƒํ’ˆ์— ๋Œ€ํ•œ ํŒ๋งค๋ช…, ํŒ๋งค๊ธˆ์•ก, ํŒ๋งค๋ธŒ๋žœ๋“œ, ์ด๋ฏธ์ง€, ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. +3. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ณ„๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜ ์žˆ๋‹ค. +4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์ƒํ’ˆ์—๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ์ˆ˜์žˆ๋‹ค. +5. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. +6. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. + +-[๊ธฐ๋Šฅ] +1. ์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +2. ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก +4. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) +5. ํŽ˜์ด์ง• + +-[์ œ์•ฝ] +1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค +2. ์ƒํ’ˆ์ด ์—†์„๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. + +--- +## ์ƒํ’ˆ ์ƒ์„ธ +1. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŒ๋งค์ค‘ ์ƒํ’ˆ(ํŒ๋งค๋ช…,ํŒ๋งค๊ธˆ์•ก,ํŒ๋งค๋ธŒ๋žœ๋“œ,์ด๋ฏธ์ง€,์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. + -[๊ธฐ๋Šฅ] +1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ๋ฒˆํ˜ธ๋กœ ์กฐํšŒ +2. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก / ์ทจ์†Œ + -[์ œ์•ฝ] +1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค +2. ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. + +--- +## ์ข‹์•„์š” +1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ ์ˆ˜ ์žˆ๋‹ค. +2. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œ ํ•  ์ˆ˜ ์žˆ๋‹ค. +2. ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด ๋ชฉ๋ก์„ ๋ณผ์ˆ˜์žˆ๋‹ค. + -[๊ธฐ๋Šฅ] +1. ์ข‹์•„์š” ๋ˆ„๋ฅธ ์ƒํ’ˆ์—๋Œ€ํ•ด ๋ชฉ๋ก ์กฐํšŒ +2. ์‚ฌ์šฉ๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•œ ๋“ฑ๋ก/์ทจ์†Œ, ๋‹จ ๋“ฑ๋ก/ํ•ด์ œ (๋ฉฑ๋“ฑ์„ฑ) + -[์ œ์•ฝ] +1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค +2. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ์ฒ˜์Œ ๋“ฑ๋ก ํ• ๋•Œ๋Š” 201_Created ์ œ๊ณตํ•œ๋‹ค +3. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ํ•œ๋ฒˆ๋” ๋“ฑ๋ก ํ• ๋•Œ๋Š” 200_OK ์ œ๊ณตํ•œ๋‹ค +4. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก ๋œ ์ƒํƒœ์—์„œ ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค +5. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๊ฐ€ ๋œ ์ƒํƒœ์—์„œ ํ•œ๋ฒˆ๋” ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค +--- +## ๋ธŒ๋žœ๋“œ +1. ์‚ฌ์šฉ์ž๋Š” ๋ชจ๋“  ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. +2. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๋ธŒ๋žœ๋“œ์— ๋Œ€ํ•œ ์ƒํ’ˆ๋งŒ ๋ณผ์ˆ˜์žˆ๋‹ค. +3. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๊ธฐ์ค€์œผ๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ) +4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. + [๊ธฐ๋Šฅ] +1. ๋ชจ๋“  ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ +2. ํŠน์ • ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ +3. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) +4. ํŽ˜์ด์ง• + [์ œ์•ฝ] +1. ๋ธŒ๋žœ๋“œ๊ฐ€ ์—†์„์‹œ 404_NOTFOUND๋ฅผ ์ œ๊ณตํ•œ๋‹ค +--- +## ์ฃผ๋ฌธ +1. ์‚ฌ์šฉ์ž๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก์—์„œ ์›ํ•˜๋Š” ์ƒํ’ˆ์„ ์„ ํƒํ•˜์—ฌ ์ฃผ๋ฌธํ•  ์ˆ˜ ์žˆ๋‹ค. +2. ํ•œ๊ฐœ์˜ ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ๋‹ค. +3. ์‚ฌ์šฉ์ž๊ฐ€ ์ฃผ๋ฌธ ๋‚ด์—ญ์„ ์กฐํšŒํ•ด ์–ด๋–ค ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. +4. ์‚ฌ์šฉ์ž๋Š” ๊ฒฐ์ œ ์ „์ด๋ผ๋ฉด ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•  ์ˆ˜ ์žˆ๋‹ค. +5. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธ ์ƒ์„ธ ํ™”๋ฉด์—์„œ ์ƒํ’ˆ ์ •๋ณด, ์ˆ˜๋Ÿ‰, ๊ฒฐ์ œ ๊ธˆ์•ก, ์ƒํƒœ ๋“ฑ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. + [๊ธฐ๋Šฅ] +1. ์ฃผ๋ฌธ ์ƒ์„ฑ +2. ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ +3. ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ +4. ์ฃผ๋ฌธ ์ทจ์†Œ +5. ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์ƒํƒœ๊ด€๋ฆฌ + [์ œ์•ฝ] +1. ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ ์žฌ๊ณ  ํ™•์ธ ๋ฐ ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ +2. ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ ์‹œ ์ฃผ๋ฌธ ๋ถˆ๊ฐ€ +3. ๋™์ผํ•œ ์ฃผ๋ฌธ ์š”์ฒญ์ด ์ค‘๋ณต์œผ๋กœ ๋“ค์–ด์™€๋„ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ +--- +## ๊ฒฐ์ œ +1. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธํ•œ ์ƒํ’ˆ์— ๋Œ€ํ•ด ํฌ์ธํŠธ๋กœ ๊ฒฐ์ œํ•  ์ˆ˜ ์žˆ๋‹ค. +2. ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ๊ฒฐ์ œ ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค. +3. ๊ฒฐ์ œ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๊ฒฐ์ œ ์‹คํŒจ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ํฌ์ธํŠธ์™€ ์žฌ๊ณ ๋Š” ๋ณต๊ตฌ๋œ๋‹ค. +4. ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„์—๋Š” ์ฃผ๋ฌธ ์ทจ์†Œ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค. + [๊ธฐ๋Šฅ] +1. ๊ฒฐ์ œ์š”์ฒญ +2. ๊ฒฐ์ œ ๊ฒฐ๊ณผ ๋ฐ˜์˜ +3. ๊ฒฐ์ œ ์‹คํŒจ ์ฒ˜๋ฆฌ +4. ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ + [์ œ์•ฝ] +1. ๋™์ผ ์ฃผ๋ฌธ์— ๋Œ€ํ•ด ์ค‘๋ณต ๊ฒฐ์ œ ์š”์ฒญ ์‹œ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ +2. ํฌ์ธํŠธ ์ฐจ๊ฐ์‹คํŒจ ์‹œ ๋ณต๊ตฌ +3. ์™ธ๋ถ€๊ฒฐ์ œ ์‹œ์Šคํ…œ ๊ฒฐ์ œ ์‹œ์Šคํ…œ ์ฒ˜๋ฆฌ ์‹คํŒจ์‹œ ์˜ˆ์™ธ์ฒ˜๋ฆฌ + +---- +## Ubiquitous +| ํ•œ๊ตญ์–ด | ์˜์–ด | +|--------|------| +| ์‚ฌ์šฉ์ž | User | +| ํฌ์ธํŠธ | Point | +| ์ƒํ’ˆ | Product | +| ๋ธŒ๋žœ๋“œ | Brand | +| ์ข‹์•„์š” | Like | +| ์ฃผ๋ฌธ | Order | +| ์žฌ๊ณ  | Stock | +| ๊ฐ€๊ฒฉ | Price | +| ๊ฒฐ์ œ | Payment | \ No newline at end of file From d50511593302038e3d4e01b13790016c1654507a Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 7 Nov 2025 16:58:45 +0900 Subject: [PATCH 041/164] =?UTF-8?q?docs=20:=20=EC=8B=9C=ED=80=80=EC=8A=A4?= =?UTF-8?q?=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2round/02-sequence-diagrams.md | 164 ++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/2round/02-sequence-diagrams.md diff --git a/docs/2round/02-sequence-diagrams.md b/docs/2round/02-sequence-diagrams.md new file mode 100644 index 000000000..5264a4dc0 --- /dev/null +++ b/docs/2round/02-sequence-diagrams.md @@ -0,0 +1,164 @@ +# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +### 1. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + participant BrandRepository + participant LikeRepository + + User->>ProductController: GET /api/v1/products + ProductController->>ProductService: getProductList + ProductService->>ProductRepository: findAllWithPaging + ProductService->>BrandRepository: findBrandInfoForProducts() + ProductService->>LikeRepository: countLikesForProducts() + ProductRepository-->>ProductService: productList + ProductService-->>ProductController: productListResponse + ProductController-->>User: 200 OK (์ƒํ’ˆ ๋ชฉ๋ก + ๋ธŒ๋žœ๋“œ + ์ข‹์•„์š” ์ˆ˜) +``` +--- +### 2. ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + participant BrandRepository + participant LikeRepository + + User->>ProductController: GET /api/v1/products/{productId} + ProductController->>ProductService: getProductDetail(productId, userId) + ProductService->>ProductRepository: findById(productId) + ProductService->>BrandRepository: findBrandInfo(brandId) + ProductService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + ProductRepository-->>ProductService: productDetail + ProductService-->>ProductController: productDetailResponse + ProductController-->>User: 200 OK (์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด) +``` +--- +### 3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeService + participant LikeRepository + + User->>LikeController: POST /api/v1/like/products/{productId} + LikeController->>LikeService: toggleLike(userId, productId) + LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + alt ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ + LikeService->>LikeRepository: save(userId, productId) + LikeService-->>LikeController: 201 Created + else ์ด๋ฏธ ์ข‹์•„์š” ๋˜์–ด์žˆ์Œ + LikeService->>LikeRepository: delete(userId, productId) + LikeService-->>LikeController: 204 No Content + end + LikeController-->>User: ์‘๋‹ต (์ƒํƒœ์ฝ”๋“œ์— ๋”ฐ๋ผ ๋‹ค๋ฆ„) +``` +--- + +### 4. ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant ProductRepository + participant BrandRepository + + User->>BrandController: GET /api/v1/brands/{brandId}/products + BrandController->>BrandService: getProductsByBrand(brandId, sort, page) + BrandService->>BrandRepository: findById(brandId) + BrandService->>ProductRepository: findByBrandId(brandId, sort, page) + BrandRepository-->>BrandService: brandInfo + ProductRepository-->>BrandService: productList + BrandService-->>BrandController: productListResponse + BrandController-->>User: 200 OK (๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก) +``` +--- +### 5. ์ฃผ๋ฌธ ์ƒ์„ฑ +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderService + participant ProductReader + participant StockService + participant PointService + participant OrderRepository + + User->>OrderController: POST /api/v1/orders (items[]) + OrderController->>OrderService: createOrder(userId, items) + OrderService->>ProductReader: getProductsByIds(productIds) + loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด + OrderService->>StockService: checkAndDecreaseStock(productId, quantity) + end + OrderService->>PointService: deductPoint(userId, totalPrice) + alt ์žฌ๊ณ  ๋˜๋Š” ํฌ์ธํŠธ ๋ถ€์กฑ + OrderService-->>OrderController: throw Exception + OrderController-->>User: 400 Bad Request + else ์ •์ƒ + OrderService->>OrderRepository: save(order, orderItems) + OrderService-->>OrderController: OrderResponse + OrderController-->>User: 201 Created (์ฃผ๋ฌธ ์™„๋ฃŒ) + end +``` +--- +### 6. ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ ์ƒ์„ธ ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderService + participant OrderRepository + participant ProductRepository + + User->>OrderController: GET /api/v1/orders + OrderController->>OrderService: getOrderList(userId) + OrderService->>OrderRepository: findByUserId(userId) + OrderRepository-->>OrderService: orderList + OrderService-->>OrderController: orderListResponse + OrderController-->>User: 200 OK (์ฃผ๋ฌธ ๋ชฉ๋ก) + + User->>OrderController: GET /api/v1/orders/{orderId} + OrderController->>OrderService: getOrderDetail(orderId, userId) + OrderService->>OrderRepository: findById(orderId) + OrderService->>ProductRepository: findProductsInOrder(orderId) + OrderRepository-->>OrderService: orderDetail + OrderService-->>OrderController: orderDetailResponse + OrderController-->>User: 200 OK (์ฃผ๋ฌธ ์ƒ์„ธ) +``` +--- +### 7. ๊ฒฐ์ œ ์ฒ˜๋ฆฌ +```mermaid +sequenceDiagram + participant User + participant PaymentController + participant PaymentService + participant PaymentGateway + participant OrderRepository + participant PointService + participant StockService + + User->>PaymentController: POST /api/v1/payments (orderId) + PaymentController->>PaymentService: processPayment(orderId, userId) + PaymentService->>OrderRepository: findById(orderId) + PaymentService->>PaymentGateway: requestPayment(orderId, amount) + alt ๊ฒฐ์ œ ์„ฑ๊ณต + PaymentGateway-->>PaymentService: SUCCESS + PaymentService->>OrderRepository: updateStatus(orderId, PAID) + PaymentService-->>PaymentController: successResponse + PaymentController-->>User: 200 OK (๊ฒฐ์ œ ์™„๋ฃŒ) + else ๊ฒฐ์ œ ์‹คํŒจ + PaymentGateway-->>PaymentService: FAILED + PaymentService->>PointService: rollbackPoint(userId, amount) + PaymentService->>StockService: restoreStock(orderId) + PaymentService->>OrderRepository: updateStatus(orderId, FAILED) + PaymentController-->>User: 500 Internal Server Error (๊ฒฐ์ œ ์‹คํŒจ) + end +``` From 624b780acec71c75f283a61398e661f7faa545ee Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 7 Nov 2025 16:58:59 +0900 Subject: [PATCH 042/164] =?UTF-8?q?docs=20:=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B0=9D=EC=B2=B4=20=EC=84=A4=EA=B3=84=20(=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2round/03-class-diagram.md | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/2round/03-class-diagram.md diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md new file mode 100644 index 000000000..19a4758e6 --- /dev/null +++ b/docs/2round/03-class-diagram.md @@ -0,0 +1,83 @@ +# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +```mermaid +classDiagram +direction TB + +class User { + Long id + String userId + String name + String email + String gender +} + +class Point { + Long id + Long userId + Long amount +} + +class Brand { + Long id + String name + String description +} + +class Product { + Long id + Long brandId + String name + Long price + String status +} + +class Stock { + Long productId + int quantity +} + +class Like { + Long id + String userId + Long productId + LocalDateTime createdAt +} + +class Order { + Long id + String userId + String status + Long totalPrice + LocalDateTime createdAt + List orderItems +} + +class OrderItem { + Long id + Long orderId + Long productId + int quantity + Long priceSnapshot +} + +class Payment { + Long id + Long orderId + String status + String paymentRequestId + LocalDateTime createdAt +} + +%% ๊ด€๊ณ„ ์„ค์ • +User --> Point +Brand --> Product +Product --> Stock +Product --> Like +User --> Like +User --> Order +Order --> OrderItem +Order --> Payment +OrderItem --> Product + +``` \ No newline at end of file From a97d77bd5a4c4ee7a1b58dd2b973b34484622828 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 7 Nov 2025 16:59:06 +0900 Subject: [PATCH 043/164] =?UTF-8?q?docs=20:=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EA=B5=AC=EC=A1=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2round/04-erd.md | 79 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/2round/04-erd.md diff --git a/docs/2round/04-erd.md b/docs/2round/04-erd.md new file mode 100644 index 000000000..afc7cb8be --- /dev/null +++ b/docs/2round/04-erd.md @@ -0,0 +1,79 @@ +# ์—”ํ‹ฐํ‹ฐ ๋‹ค์ด์–ด๊ทธ๋žจ +```mermaid +erDiagram + USER { + bigint id PK + varchar user_id + varchar name + varchar email + varchar gender + } + + POINT { + bigint id PK + varchar user_id FK + bigint amount + } + + BRAND { + bigint id PK + varchar name + varchar description + } + + PRODUCT { + bigint id PK + bigint brand_id FK + varchar name + bigint price + varchar status + } + + STOCK { + bigint product_id PK, FK + int quantity + } + + LIKE { + bigint id PK + varchar user_id FK + bigint product_id FK + datetime created_at + } + + ORDERS { + bigint id PK + varchar user_id FK + varchar status + bigint total_price + datetime created_at + } + + ORDER_ITEM { + bigint id PK + bigint order_id FK + bigint product_id FK + int quantity + bigint price_snapshot + } + + PAYMENT { + bigint id PK + bigint order_id FK + varchar status + varchar payment_request_id + datetime created_at + } + + %% ๊ด€๊ณ„ ์„ค์ • (ํ•œ๊ธ€ ๋ฒ„์ „) + USER ||--|| POINT : "" + BRAND ||--o{ PRODUCT : "" + PRODUCT ||--|| STOCK : "" + PRODUCT ||--o{ LIKE : "" + USER ||--o{ LIKE : "" + USER ||--o{ ORDERS : "" + ORDERS ||--o{ ORDER_ITEM : "" + ORDERS ||--|| PAYMENT : "" + ORDER_ITEM }o--|| PRODUCT : "" + +``` \ No newline at end of file From 55f8c8b453da282f4adc3361abb8552e8880bb33 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 7 Nov 2025 16:59:16 +0900 Subject: [PATCH 044/164] =?UTF-8?q?docs=20:=202round=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2round/2round.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/2round/2round.md diff --git a/docs/2round/2round.md b/docs/2round/2round.md new file mode 100644 index 000000000..84fdc982c --- /dev/null +++ b/docs/2round/2round.md @@ -0,0 +1,37 @@ +## โœ๏ธ Design Quest + +> **์ด์ปค๋จธ์Šค ๋„๋ฉ”์ธ(์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๋“ฑ)์— ๋Œ€ํ•œ ์„ค๊ณ„**๋ฅผ ์™„๋ฃŒํ•˜๊ณ , ๋‹ค์Œ ์ฃผ๋ถ€ํ„ฐ ๊ฐœ๋ฐœ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€์˜ ์„ค๊ณ„ ๋ฌธ์„œ๋ฅผ ์ •๋ฆฌํ•˜์—ฌ PR๋กœ ์ œ์ถœํ•ฉ๋‹ˆ๋‹ค. +> + +### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด + +- **์„ค๊ณ„ ๋ฒ”์œ„** + - ์ƒํ’ˆ ๋ชฉ๋ก / ์ƒํ’ˆ ์ƒ์„ธ / ๋ธŒ๋žœ๋“œ ์กฐํšŒ + - ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ (๋ฉฑ๋“ฑ ๋™์ž‘) + - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ๊ฒฐ์ œ ํ๋ฆ„ (์žฌ๊ณ  ์ฐจ๊ฐ, ํฌ์ธํŠธ ์ฐจ๊ฐ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™) +- **์ œ์™ธ ๋„๋ฉ”์ธ** + - ํšŒ์›๊ฐ€์ž…, ํฌ์ธํŠธ ์ถฉ์ „ (1์ฃผ์ฐจ ๊ตฌํ˜„ ์™„๋ฃŒ ๊ธฐ์ค€) +- **์š”๊ตฌ์‚ฌํ•ญ ๊ธฐ๋ฐ˜** + - ๋ฃจํ”„ํŒฉ ์ด์ปค๋จธ์Šค ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฌธ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๋Šฅ/์ œ์•ฝ์‚ฌํ•ญ์„ ์„ค๊ณ„์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. +- **์ œ์ถœ ๋ฐฉ์‹** + 1. ์•„๋ž˜ ํŒŒ์ผ๋“ค์„ ํ”„๋กœ์ ํŠธ ๋‚ด `docs/week2/` ํด๋”์— `.md`๋กœ ์ €์žฅ + 2. Github PR๋กœ ์ œ์ถœ + - PR ์ œ๋ชฉ: `[2์ฃผ์ฐจ] ์„ค๊ณ„ ๋ฌธ์„œ ์ œ์ถœ - ํ™๊ธธ๋™` + - PR ๋ณธ๋ฌธ์— ๋ฆฌ๋ทฐ ํฌ์ธํŠธ ํฌํ•จ (์˜ˆ: ๊ณ ๋ฏผํ•œ ์ง€์  ๋“ฑ) + +### โœ… ์ œ์ถœ ํŒŒ์ผ ๋ชฉ๋ก (.docs/design ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด) + +| ํŒŒ์ผ๋ช… | ๋‚ด์šฉ | +| --- | --- | +| `01-requirements.md` | ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค ๊ธฐ๋ฐ˜ ๊ธฐ๋Šฅ ์ •์˜, ์š”๊ตฌ์‚ฌํ•ญ ๋ช…์„ธ | +| `02-sequence-diagrams.md` | ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ ์ตœ์†Œ 2๊ฐœ ์ด์ƒ (Mermaid ๊ธฐ๋ฐ˜ ์ž‘์„ฑ ๊ถŒ์žฅ) | +| `03-class-diagram.md` | ๋„๋ฉ”์ธ ๊ฐ์ฒด ์„ค๊ณ„ (ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ or ์„ค๋ช… ์ค‘์‹ฌ) | +| `04-erd.md` | ์ „์ฒด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ๋ฐ ๊ด€๊ณ„ ์ •๋ฆฌ (ERD Mermaid ์ž‘์„ฑ ๊ฐ€๋Šฅ) | + +## โœ… Checklist + +- [ ] ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ/์ข‹์•„์š”/์ฃผ๋ฌธ ๋„๋ฉ”์ธ์ด ๋ชจ๋‘ ํฌํ•จ๋˜์–ด ์žˆ๋Š”๊ฐ€? +- [ ] ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์ด ์œ ์ € ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ๋˜์–ด ์žˆ๋Š”๊ฐ€? +- [ ] ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์—์„œ ์ฑ…์ž„ ๊ฐ์ฒด๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š”๊ฐ€? +- [ ] ํด๋ž˜์Šค ๊ตฌ์กฐ๊ฐ€ ๋„๋ฉ”์ธ ์„ค๊ณ„๋ฅผ ์ž˜ ํ‘œํ˜„ํ•˜๊ณ  ์žˆ๋Š”๊ฐ€? +- [ ] ERD ์„ค๊ณ„ ์‹œ ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์„ ๊ณ ๋ คํ•˜์—ฌ ๊ตฌ์„ฑํ•˜์˜€๋Š”๊ฐ€? \ No newline at end of file From 592a4e5e829a8c234dbd356abb76b5d81edcb709 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 7 Nov 2025 16:59:24 +0900 Subject: [PATCH 045/164] =?UTF-8?q?docs=20:=201round=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/{ => 1round}/1round.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{ => 1round}/1round.md (100%) diff --git a/docs/1round.md b/docs/1round/1round.md similarity index 100% rename from docs/1round.md rename to docs/1round/1round.md From 4faa67e71c2e45c6d3d2c09ef61ceecbe46a2177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Sat, 8 Nov 2025 12:43:45 +0900 Subject: [PATCH 046/164] =?UTF-8?q?round1:=20=EB=A6=AC=EB=B7=B0=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 --- .../java/com/loopers/domain/point/Point.java | 2 + .../domain/user/UserJpaRepository.java | 7 + .../com/loopers/domain/user/UserService.java | 2 +- .../interfaces/api/point/PointV1ApiSpec.java | 2 +- .../api/point/PointV1Controller.java | 3 + .../point/PointServiceIntegrationTest.java | 2 +- .../loopers/domain/user/UserModelTest.java | 170 ++++-------------- 7 files changed, 48 insertions(+), 140 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index d27ebbe22..12c0c00c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -8,9 +8,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import lombok.Getter; @Entity @Table(name = "point") +@Getter public class Point extends BaseEntity { @ManyToOne @JoinColumn(referencedColumnName = "id", nullable = false, updatable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java index 7f6fb8222..d44b8e3db 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java @@ -1,6 +1,9 @@ package com.loopers.domain.user; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -9,5 +12,9 @@ public interface UserJpaRepository extends JpaRepository { Optional findByUserId(String userId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM User u WHERE u.userId = :userId") + Optional findByUserIdForUpdate(String userId); + boolean existsUserByUserId(String userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index be907604a..dc628535d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -31,7 +31,7 @@ public Optional findByUserId(String userId) { // find by user id with lock for update @Transactional public Optional findByUserIdForUpdate(String userId) { - return userJpaRepository.findByUserId(userId); + return userJpaRepository.findByUserIdForUpdate(userId); } @Transactional(readOnly = true) public Optional getCurrentPoint(String userId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java index bfb935ad2..fb18c23d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -5,7 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "Point V1 API", description = "์‚ฌ์šฉ์ž API ์ž…๋‹ˆ๋‹ค.") +@Tag(name = "Point V1 API", description = "ใ…ใ…—์ธํŠธ API ์ž…๋‹ˆ๋‹ค.") public interface PointV1ApiSpec { // /points diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index d2fb3ce63..f92e19cd1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -31,6 +31,9 @@ public ApiResponse getUserPoints(@RequestHeader(value public ApiResponse chargeUserPoints( @RequestHeader(value = "X-USER-ID", required = false) String userId, @RequestBody PointV1Dto.PointChargeRequest request) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } Long chargedPoint = pointFacade.chargePoints(userId, request.amount()); PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(chargedPoint); return ApiResponse.success(response); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index a2097aef9..49dd27c15 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -1,11 +1,11 @@ package com.loopers.domain.point; import com.loopers.support.error.CoreException; -import jakarta.transaction.Transactional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 45adade80..03b394446 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -17,7 +19,6 @@ class Create { private final String validBirthday = "1993-03-13"; private final String validGender = "male"; - // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") @Test @@ -34,26 +35,15 @@ void throwsException_whenIdIsInvalidFormat_Null() { assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } - @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์˜๋ฌธ ๋ฐ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ") - @Test - void throwsException_whenIdIsInvalidFormat_NotAlphanumeric() { - // arrange - String invalidId = "user!@#"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ดˆ๊ณผ์ธ ๊ฒฝ์šฐ") - @Test - void throwsException_whenIdIsInvalidFormat_TooLong() { - // arrange - String invalidId = "user1234567"; + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null์•„๋‹Œ ์—ฌ๋Ÿฌ ์ž˜๋ชป๋œ ํ˜•์‹๋“ค") + @ParameterizedTest + @ValueSource(strings = { + "", // ๋นˆ ๋ฌธ์ž์—ด + "user!@#", // ์˜๋ฌธ ๋ฐ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ + "user1234567" // ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ดˆ๊ณผ์ธ ๊ฒฝ์šฐ + }) + void throwsException_whenIdIsInvalidFormat(String invalidId) { + // arrange: invalidId parameter // act CoreException result = assertThrows(CoreException.class, () -> { @@ -85,57 +75,18 @@ void throwsException_whenEmailIsInvalidFormat_Null() { assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } - @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - @๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ") - @Test - void throwsException_whenEmailIsInvalidFormat_MissingAtSymbol() { - // arrange - String invalidEmail = "userexample.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ๋„๋ฉ”์ธ ๋ถ€๋ถ„์ด ์—†๋Š” ๊ฒฝ์šฐ") - @Test - void throwsException_whenEmailIsInvalidFormat_MissingDomain() { - // arrange - String invalidEmail = "user@.com"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ์ด ์—†๋Š” ๊ฒฝ์šฐ") - @Test - void throwsException_whenEmailIsInvalidFormat_MissingTopLevelDomain() { - // arrange - String invalidEmail = "user@example"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - @.๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ") - @Test - void throwsException_whenEmailIsInvalidFormat_OnlyAtAndDot() { - // arrange - String invalidEmail = "@."; + @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ด ์•„๋‹Œ ์—ฌ๋Ÿฌ ์ž˜๋ชป๋œ ํ˜•์‹๋“ค") + @ParameterizedTest + @ValueSource(strings = { + "", // ๋นˆ ๋ฌธ์ž์—ด + "userexample.com", // @๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ + "user@.com", // ๋„๋ฉ”์ธ ๋ถ€๋ถ„์ด ์—†๋Š” ๊ฒฝ์šฐ + "user@example", // ์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ์ด ์—†๋Š” ๊ฒฝ์šฐ + "@." // @.๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ + }) + void throwsException_whenEmailIsInvalidFormat(String invalidEmail) { + // arrange: invalidEmail parameter // act CoreException result = assertThrows(CoreException.class, () -> { User.create(validId, invalidEmail, validBirthday, validGender); @@ -165,72 +116,17 @@ void throwsException_whenBirthdayIsInvalidFormat_Null() { assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 13-03-1993") - @Test - void throwsException_whenBirthdayIsInvalidFormat() { - // arrange - String invalidBirthday = "13-03-1993"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 1993/03/13") - @Test - void throwsException_whenBirthdayIsInvalidFormat_Slashes() { - // arrange - String invalidBirthday = "1993/03/13"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ์ž˜๋ชป๋œ ํ˜•์‹์ธ ๊ฒฝ์šฐ 19930313") - @Test - void throwsException_whenBirthdayIsInvalidFormat_NoSeparators() { - // arrange - String invalidBirthday = "19930313"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - 930313") - @Test - void throwsException_whenBirthdayIsInvalidFormat_ShortDate() { - // arrange - String invalidBirthday = "930313"; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - ๋นˆ ๋ฌธ์ž์—ด") - @Test - void throwsException_whenBirthdayIsInvalidFormat_EmptyString() { - // arrange - String invalidBirthday = ""; - + @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null์ด ์•„๋‹Œ ์—ฌ๋Ÿฌ ์ž˜๋ชป๋œ ํ˜•์‹๋“ค") + @ParameterizedTest + @ValueSource(strings = { + "13-03-1993", // ์ž˜๋ชป๋œ ํ˜•์‹ + "1993/03/13", // ์ž˜๋ชป๋œ ํ˜•์‹ + "19930313", // ์ž˜๋ชป๋œ ํ˜•์‹ + "930313", // ์ž˜๋ชป๋œ ํ˜•์‹ + "" // ๋นˆ ๋ฌธ์ž์—ด + }) + void throwsException_whenBirthdayIsInvalidFormat(String invalidBirthday) { + // arrange: invalidBirthday parameter // act CoreException result = assertThrows(CoreException.class, () -> { User.create(validId, validEmail, invalidBirthday, validGender); From 86a8205b09436cae10c4bcd958aaf3b6828723db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 11 Nov 2025 19:02:31 +0900 Subject: [PATCH 047/164] =?UTF-8?q?round1:=20=EB=A6=AC=EB=B7=B0=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 ํฌ์ธํŠธ 1:1๋กœ ๊ด€๊ณ„ ์ˆ˜์ • ๋„๋ฉ”์ธ ๋ถ„๋ฆฌ(User.currentPoint ์ œ๊ฑฐ) --- .../application/point/PointFacade.java | 16 ++++-- .../loopers/application/user/UserFacade.java | 5 ++ .../java/com/loopers/domain/point/Point.java | 21 ++++---- .../domain/point/PointJpaRepository.java | 3 ++ .../loopers/domain/point/PointService.java | 25 +++++---- .../java/com/loopers/domain/user/User.java | 3 -- .../com/loopers/domain/user/UserService.java | 5 +- .../api/point/PointV1Controller.java | 2 +- .../loopers/domain/point/PointModelTest.java | 11 +--- .../point/PointServiceIntegrationTest.java | 52 ++++++++++++++++++- .../user/UserServiceIntegrationTest.java | 39 -------------- 11 files changed, 101 insertions(+), 81 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java index 1a9af650f..126a528de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -1,24 +1,32 @@ package com.loopers.application.point; import com.loopers.domain.point.PointService; +import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component public class PointFacade { - private final UserService userService; private final PointService pointService; + private final UserService userService; + @Transactional(readOnly = true) public Long getCurrentPoint(String userId) { - return userService.getCurrentPoint(userId) + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return pointService.getCurrentPoint(user.getId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } - public Long chargePoints(String userId, int amount) { - return pointService.chargePoint(userId, amount); + @Transactional + public Long chargePoint(String userId, int amount) { + User user = userService.findByUserIdForUpdate(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return pointService.chargePoint(user.getId(), amount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java index 848eed169..030d014b6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -1,11 +1,13 @@ package com.loopers.application.user; +import com.loopers.domain.point.PointService; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -13,9 +15,12 @@ @Component public class UserFacade { private final UserService userService; + private final PointService pointService; + @Transactional public UserInfo registerUser(String userId, String email, String birthday, String gender) { User registeredUser = userService.registerUser(userId, email, birthday, gender); + pointService.createPoint(registeredUser.getId()); return UserInfo.from(registeredUser); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 12c0c00c8..8673786b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -1,12 +1,10 @@ package com.loopers.domain.point; import com.loopers.domain.BaseEntity; -import com.loopers.domain.user.User; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.Getter; @@ -14,24 +12,27 @@ @Table(name = "point") @Getter public class Point extends BaseEntity { - @ManyToOne - @JoinColumn(referencedColumnName = "id", nullable = false, updatable = false) - private User user; + @Column(name = "user_id", nullable = false, updatable = false, unique = true) + private Long userId; private Long amount; protected Point() { } - private Point(User user, Long amount) { - this.user = user; + private Point(Long userId, Long amount) { + this.userId = userId; this.amount = amount; } - public static Point create(User user, int amount) { + public static Point create(Long userId) { + return new Point(userId, 0L); + } + + public void charge(int amount) { if (amount <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } - return new Point(user, (long) amount); + this.amount += amount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java index 2c1e365a2..1a8ea9b67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java @@ -3,7 +3,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface PointJpaRepository extends JpaRepository { + Optional findByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index ce0960041..723bf078f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -1,28 +1,35 @@ package com.loopers.domain.point; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @RequiredArgsConstructor @Service public class PointService { private final PointJpaRepository pointJpaRepository; - private final UserService userService; @Transactional - public Long chargePoint(String userId, int amount) { - User user = userService.findByUserIdForUpdate(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - Point point = Point.create(user, amount); + public void createPoint(Long userId) { + Point point = Point.create(userId); + pointJpaRepository.save(point); + } - user.setCurrentPoint(user.getCurrentPoint() + amount); + @Transactional + public Long chargePoint(Long userId, int amount) { + Point point = pointJpaRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + point.charge(amount); pointJpaRepository.save(point); + return point.getAmount(); + } - return user.getCurrentPoint(); + @Transactional(readOnly = true) + public Optional getCurrentPoint(Long userId) { + return pointJpaRepository.findByUserId(userId).map(Point::getAmount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 908a5efac..eefd20d78 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -7,7 +7,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.Getter; -import lombok.Setter; import org.apache.commons.lang3.StringUtils; @Entity @@ -22,8 +21,6 @@ protected User() { private String email; private String birthday; private String gender; - @Setter - private Long currentPoint = 0L; private User(String userId, String email, String birthday, String gender) { this.userId = userId; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index dc628535d..c4e9d6fd5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -33,8 +33,5 @@ public Optional findByUserId(String userId) { public Optional findByUserIdForUpdate(String userId) { return userJpaRepository.findByUserIdForUpdate(userId); } - @Transactional(readOnly = true) - public Optional getCurrentPoint(String userId) { - return findByUserId(userId).map(User::getCurrentPoint); - } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index f92e19cd1..285e70a04 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -34,7 +34,7 @@ public ApiResponse chargeUserPoints( if (StringUtils.isBlank(userId)) { throw new CoreException(ErrorType.BAD_REQUEST); } - Long chargedPoint = pointFacade.chargePoints(userId, request.amount()); + Long chargedPoint = pointFacade.chargePoint(userId, request.amount()); PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(chargedPoint); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java index 6eb3f5f72..8e702e629 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -1,6 +1,5 @@ package com.loopers.domain.point; -import com.loopers.domain.user.User; import com.loopers.support.error.CoreException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -20,15 +19,9 @@ class Create { @ValueSource(ints = {0, -10, -100}) void throwsException_whenPointIsZeroOrNegative(int invalidPoint) { // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - - User user = User.create(validId, validEmail, validBirthday, validGender); - + Point point = Point.create(0L); // act - CoreException result = assertThrows(CoreException.class, () -> Point.create(user, invalidPoint)); + CoreException result = assertThrows(CoreException.class, () -> point.charge(invalidPoint)); // assert assertThat(result.getMessage()).isEqualTo("์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index 49dd27c15..b22645d4f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -1,19 +1,68 @@ package com.loopers.domain.point; +import com.loopers.application.point.PointFacade; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest @Transactional public class PointServiceIntegrationTest { + @Autowired + private PointFacade pointFacade; @Autowired private PointService pointService; + @Autowired + private UserService userService; + + @BeforeEach + void setUp() { + String validId = "user123"; + String validEmail = "xx@yy.zz"; + String validBirthday = "1993-03-13"; + String validGender = "male"; + // ์œ ์ € ๋“ฑ๋ก + User registeredUser = userService.registerUser(validId, validEmail, validBirthday, validGender); + pointService.createPoint(registeredUser.getId()); + } + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsUserPoints_whenUserExists() { + // arrange: setUp() ๋ฉ”์„œ๋“œ์—์„œ ์ด๋ฏธ ์œ ์ € ๋“ฑ๋ก + String existingUserId = "user123"; + Long userId = userService.findByUserId(existingUserId).get().getId(); + + // act + Optional currentPoint = pointService.getCurrentPoint(userId); + + // assert + assertThat(currentPoint.orElse(null)).isEqualTo(0L); + } + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsNullPoints_whenUserDoesNotExist() { + // arrange: setUp() ๋ฉ”์„œ๋“œ์—์„œ ๋“ฑ๋ก๋˜์ง€ ์•Š์€ ์œ ์ € ID ์‚ฌ์šฉ + Long nonExistingUserId = -1L; + + // act + Optional currentPoint = pointService.getCurrentPoint(nonExistingUserId); + + // assert + assertThat(currentPoint).isNotPresent(); + } //์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") @@ -24,7 +73,6 @@ void throwsExceptionWhenChargePointWithNonExistingUserId() { int chargeAmount = 1000; // act & assert - assertThrows(CoreException.class, () -> pointService.chargePoint(nonExistingUserId, chargeAmount)); + assertThrows(CoreException.class, () -> pointFacade.chargePoint(nonExistingUserId, chargeAmount)); } - } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index b7698e76c..299efaf2c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -105,43 +105,4 @@ void returnsNull_whenUserDoesNotExist() { // assert assertThat(foundUser).isNotPresent(); } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsUserPoints_whenUserExists() { - // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - // ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday, validGender); - String existingUserId = "user123"; - - // act - Optional currentPoint = userService.getCurrentPoint(existingUserId); - - // assert - assertThat(currentPoint).isPresent(); - assertThat(currentPoint.get()).isEqualTo(0L); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsNullPoints_whenUserDoesNotExist() { - // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - // ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday, validGender); - String nonExistingId = "nonexist"; - - // act - Optional currentPoint = userService.getCurrentPoint(nonExistingId); - - // assert - assertThat(currentPoint).isNotPresent(); - } } From d06a0d0f5830cc99a98236ef419a37c2355cc5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Wed, 12 Nov 2025 02:27:25 +0900 Subject: [PATCH 048/164] =?UTF-8?q?round1:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20-=20JPA=20repo=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=EC=B2=B4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/point/Point.java | 2 +- .../loopers/domain/point/PointRepository.java | 9 +++++ .../loopers/domain/point/PointService.java | 10 +++--- .../java/com/loopers/domain/user/User.java | 2 +- .../loopers/domain/user/UserRepository.java | 13 +++++++ .../com/loopers/domain/user/UserService.java | 10 +++--- .../point/PointJpaRepository.java | 5 ++- .../point/PointRepositoryImpl.java | 24 +++++++++++++ .../user/UserJpaRepository.java | 5 ++- .../user/UserRepositoryImpl.java | 34 +++++++++++++++++++ .../user/UserServiceIntegrationTest.java | 2 +- 11 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java rename apps/commerce-api/src/main/java/com/loopers/{domain => infrastructure}/point/PointJpaRepository.java (69%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java rename apps/commerce-api/src/main/java/com/loopers/{domain => infrastructure}/user/UserJpaRepository.java (85%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 8673786b4..dbf1bcd61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -9,7 +9,7 @@ import lombok.Getter; @Entity -@Table(name = "point") +@Table(name = "tb_point") @Getter public class Point extends BaseEntity { @Column(name = "user_id", nullable = false, updatable = false, unique = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java new file mode 100644 index 000000000..07b90479c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.point; + +import java.util.Optional; + +public interface PointRepository { + Optional findByUserId(Long userId); + + void save(Point point); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 723bf078f..f557f79e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -11,25 +11,25 @@ @RequiredArgsConstructor @Service public class PointService { - private final PointJpaRepository pointJpaRepository; + private final PointRepository pointRepository; @Transactional public void createPoint(Long userId) { Point point = Point.create(userId); - pointJpaRepository.save(point); + pointRepository.save(point); } @Transactional public Long chargePoint(Long userId, int amount) { - Point point = pointJpaRepository.findByUserId(userId) + Point point = pointRepository.findByUserId(userId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); point.charge(amount); - pointJpaRepository.save(point); + pointRepository.save(point); return point.getAmount(); } @Transactional(readOnly = true) public Optional getCurrentPoint(Long userId) { - return pointJpaRepository.findByUserId(userId).map(Point::getAmount); + return pointRepository.findByUserId(userId).map(Point::getAmount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index eefd20d78..bd8bc6adf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -10,7 +10,7 @@ import org.apache.commons.lang3.StringUtils; @Entity -@Table(name = "user") +@Table(name = "tb_user") @Getter public class User extends BaseEntity { protected User() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..90f701fbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + Optional findByUserId(String userId); + + Optional findByUserIdForUpdate(String userId); + + boolean existsUserByUserId(String userId); + + User save(User user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index c4e9d6fd5..8b1129b45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -11,27 +11,27 @@ @RequiredArgsConstructor @Service public class UserService { - private final UserJpaRepository userJpaRepository; + private final UserRepository userRepository; @Transactional public User registerUser(String userId, String email, String birthday, String gender) { // ์ด๋ฏธ ๋“ฑ๋ก๋œ userId ์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค. - if (userJpaRepository.existsUserByUserId(userId)) { + if (userRepository.existsUserByUserId(userId)) { throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); } User user = User.create(userId, email, birthday, gender); - return userJpaRepository.save(user); + return userRepository.save(user); } @Transactional(readOnly = true) public Optional findByUserId(String userId) { - return userJpaRepository.findByUserId(userId); + return userRepository.findByUserId(userId); } // find by user id with lock for update @Transactional public Optional findByUserIdForUpdate(String userId) { - return userJpaRepository.findByUserIdForUpdate(userId); + return userRepository.findByUserIdForUpdate(userId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java similarity index 69% rename from apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java index 1a8ea9b67..74320f6a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -1,11 +1,10 @@ -package com.loopers.domain.point; +package com.loopers.infrastructure.point; +import com.loopers.domain.point.Point; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; import java.util.Optional; -@Repository public interface PointJpaRepository extends JpaRepository { Optional findByUserId(Long userId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java new file mode 100644 index 000000000..052de7762 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PointRepositoryImpl implements PointRepository { + private final PointJpaRepository pointJpaRepository; + + @Override + public Optional findByUserId(Long userId) { + return pointJpaRepository.findByUserId(userId); + } + + @Override + public void save(Point point) { + pointJpaRepository.save(point); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index d44b8e3db..0f298e023 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,14 +1,13 @@ -package com.loopers.domain.user; +package com.loopers.infrastructure.user; +import com.loopers.domain.user.User; import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; import java.util.Optional; -@Repository public interface UserJpaRepository extends JpaRepository { Optional findByUserId(String userId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..2018487e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findByUserId(String userId) { + return userJpaRepository.findByUserId(userId); + } + + @Override + public Optional findByUserIdForUpdate(String userId) { + return userJpaRepository.findByUserIdForUpdate(userId); + } + + @Override + public boolean existsUserByUserId(String userId) { + return userJpaRepository.existsUserByUserId(userId); + } + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java index 299efaf2c..0bd755cdf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -22,7 +22,7 @@ public class UserServiceIntegrationTest { private UserService userService; @MockitoSpyBean - private UserJpaRepository spyUserRepository; + private UserRepository spyUserRepository; @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") @Test From 5042c22dc6520c7bbb96a2c626346c6f16751e0a Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 14 Nov 2025 16:45:18 +0900 Subject: [PATCH 049/164] =?UTF-8?q?docs=20:=202round=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=2003-class-diagram.md=2004-erd.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/2round/03-class-diagram.md | 19 +++++++-------- docs/2round/04-erd.md | 41 +++++++++++++++------------------ 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md index 19a4758e6..45421dc8b 100644 --- a/docs/2round/03-class-diagram.md +++ b/docs/2round/03-class-diagram.md @@ -14,14 +14,13 @@ class User { class Point { Long id - Long userId - Long amount + String userId + Long balance } class Brand { Long id String name - String description } class Product { @@ -29,7 +28,8 @@ class Product { Long brandId String name Long price - String status + Long likeCount; + Long stock } class Stock { @@ -47,18 +47,19 @@ class Like { class Order { Long id String userId - String status Long totalPrice + OrderStatus status LocalDateTime createdAt List orderItems } class OrderItem { Long id - Long orderId + Order order Long productId - int quantity - Long priceSnapshot + String productName + Long quantity + Long price } class Payment { @@ -71,7 +72,7 @@ class Payment { %% ๊ด€๊ณ„ ์„ค์ • User --> Point -Brand --> Product +Brand --> Product Product --> Stock Product --> Like User --> Like diff --git a/docs/2round/04-erd.md b/docs/2round/04-erd.md index afc7cb8be..6389b2202 100644 --- a/docs/2round/04-erd.md +++ b/docs/2round/04-erd.md @@ -1,4 +1,5 @@ -# ์—”ํ‹ฐํ‹ฐ ๋‹ค์ด์–ด๊ทธ๋žจ +# erd + ```mermaid erDiagram USER { @@ -12,13 +13,12 @@ erDiagram POINT { bigint id PK varchar user_id FK - bigint amount + bigint balance } BRAND { bigint id PK varchar name - varchar description } PRODUCT { @@ -26,12 +26,8 @@ erDiagram bigint brand_id FK varchar name bigint price - varchar status - } - - STOCK { - bigint product_id PK, FK - int quantity + bigint like_count + bigint stock } LIKE { @@ -44,8 +40,8 @@ erDiagram ORDERS { bigint id PK varchar user_id FK + bigint total_amount varchar status - bigint total_price datetime created_at } @@ -53,8 +49,9 @@ erDiagram bigint id PK bigint order_id FK bigint product_id FK - int quantity - bigint price_snapshot + varchar product_name + bigint quantity + bigint price } PAYMENT { @@ -65,15 +62,13 @@ erDiagram datetime created_at } - %% ๊ด€๊ณ„ ์„ค์ • (ํ•œ๊ธ€ ๋ฒ„์ „) - USER ||--|| POINT : "" - BRAND ||--o{ PRODUCT : "" - PRODUCT ||--|| STOCK : "" - PRODUCT ||--o{ LIKE : "" - USER ||--o{ LIKE : "" - USER ||--o{ ORDERS : "" - ORDERS ||--o{ ORDER_ITEM : "" - ORDERS ||--|| PAYMENT : "" - ORDER_ITEM }o--|| PRODUCT : "" - + %% ๊ด€๊ณ„ (cardinality) + USER ||--|| POINT : "1:1" + BRAND ||--o{ PRODUCT : "1:N" + PRODUCT ||--o{ LIKE : "1:N" + USER ||--o{ LIKE : "1:N" + USER ||--o{ ORDERS : "1:N" + ORDERS ||--o{ ORDER_ITEM : "1:N" + ORDER_ITEM }o--|| PRODUCT : "N:1" + ORDERS ||--|| PAYMENT : "1:1" ``` \ No newline at end of file From 4fda674eb233af0586f8757a8fc2d68d46e2cb42 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 14 Nov 2025 16:45:30 +0900 Subject: [PATCH 050/164] =?UTF-8?q?docs=20:=203round=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/3round/3round.md | 60 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/3round/3round.md diff --git a/docs/3round/3round.md b/docs/3round/3round.md new file mode 100644 index 000000000..61df49f68 --- /dev/null +++ b/docs/3round/3round.md @@ -0,0 +1,60 @@ +# ๐Ÿ“ Round 3 Quests + +--- + +## ๐Ÿ’ป Implementation Quest + +> *** ๋„๋ฉ”์ธ ๋ชจ๋ธ๋ง**์„ ํ†ตํ•ด Product, Brand, Like, Order ๊ธฐ๋Šฅ์˜ ํ•ต์‹ฌ ๊ฐœ๋…์„ **Entity, Value Object, Domain Service ๋“ฑ ์ ํ•ฉํ•œ** **์ฝ”๋“œ**๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. +* ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ + DIP ๋ฅผ ์ ์šฉํ•ด ์œ ์—ฐํ•˜๊ณ  ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. +* **Application Layer๋ฅผ ๊ฒฝ๋Ÿ‰ ์ˆ˜์ค€**์œผ๋กœ ๊ตฌ์„ฑํ•˜์—ฌ, ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ์‹ค์ œ ๊ตฌํ˜„ํ•ด๋ด…๋‹ˆ๋‹ค. +* **๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑ**ํ•˜์—ฌ ๋„๋ฉ”์ธ ๋กœ์ง์˜ ์ •ํ•ฉ์„ฑ๊ณผ ๊ทœ์น™์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. +> + +### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด + +- ์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ธฐ๋Šฅ์˜ **๋„๋ฉ”์ธ ๋ชจ๋ธ ๋ฐ ๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. +- ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ํ๋ฆ„์„ ์„ค๊ณ„ํ•˜๊ณ , ํ•„์š”ํ•œ ๋กœ์ง์„ **๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋กœ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- Application Layer์—์„œ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. + (์˜ˆ: `ProductFacade.getProductDetail(productId)` โ†’ `Product + Brand + Like ์กฐํ•ฉ`) +- Repository Interface ์™€ ๊ตฌํ˜„์ฒด๋Š” ๋ถ„๋ฆฌํ•˜๊ณ , ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๊ณ ๋ คํ•œ ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค. +- ๋ชจ๋“  ํ•ต์‹ฌ ๋„๋ฉ”์ธ ๋กœ์ง์— ๋Œ€ํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ์˜ˆ์™ธ/๊ฒฝ๊ณ„ ์ผ€์ด์Šค๋„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + +### ๐Ÿท Product / Brand ๋„๋ฉ”์ธ + +## โœ… Checklist + +- [x] ์ƒํ’ˆ ์ •๋ณด ๊ฐ์ฒด๋Š” ๋ธŒ๋žœ๋“œ ์ •๋ณด, ์ข‹์•„์š” ์ˆ˜๋ฅผ ํฌํ•จํ•œ๋‹ค. +- [ ] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค +- [x] ์ƒํ’ˆ์€ ์žฌ๊ณ ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๊ณ , ์ฃผ๋ฌธ ์‹œ ์ฐจ๊ฐํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค +- [x] ์žฌ๊ณ ๋Š” ๊ฐ์†Œ๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ์Œ์ˆ˜ ๋ฐฉ์ง€๋Š” ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ๋œ๋‹ค + +### ๐Ÿ‘ Like ๋„๋ฉ”์ธ + +- [x] ์ข‹์•„์š”๋Š” ์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค +- [x] ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๊ฐ€ ๊ตฌํ˜„๋˜์—ˆ๋‹ค +- [x] ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์กฐํšŒ์—์„œ ํ•จ๊ป˜ ์ œ๊ณต๋œ๋‹ค +- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ/์ค‘๋ณต ๋ฐฉ์ง€ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค + +### ๐Ÿ›’ Order ๋„๋ฉ”์ธ + +- [ ] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค +- [ ] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ, ์œ ์ € ํฌ์ธํŠธ ์ฐจ๊ฐ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค +- [ ] ์žฌ๊ณ  ๋ถ€์กฑ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋“ฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค +- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค + +### ๐Ÿงฉ ๋„๋ฉ”์ธ ์„œ๋น„์Šค + +- [ ] ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ๋กœ์ง์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค +- [ ] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ ๋„๋ฉ”์ธ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค +- [ ] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค +- [ ] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค + +### **๐Ÿงฑ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ & ์„ค๊ณ„** + +- [ ] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค + - Application โ†’ **Domain** โ† Infrastructure +- [ ] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค +- [ ] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค +- [ ] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค +- [ ] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) +- [ ] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค \ No newline at end of file From 25b423ec65fcf24fed8e99f405e2091fcf603e77 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 14 Nov 2025 16:49:20 +0900 Subject: [PATCH 051/164] =?UTF-8?q?feat(brand):=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=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 - Brand ์—”ํ‹ฐํ‹ฐ ์ƒ์„ฑ ๋ฐ ๋„๋ฉ”์ธ ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€ - BrandRepository ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜ - Brand ๋„๋ฉ”์ธ ์„œ๋น„์Šค ์„ค๊ณ„ - Brand ๋‹จ์œ„/ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ (์ƒ์„ฑ/๊ฒ€์ฆ) --- .../java/com/loopers/domain/brand/Brand.java | 47 +++++++++++++++++++ .../loopers/domain/brand/BrandRepository.java | 20 ++++++++ .../loopers/domain/brand/BrandService.java | 36 ++++++++++++++ .../brand/BrandJpaRepository.java | 21 +++++++++ .../brand/BrandRepositoryImpl.java | 36 ++++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 42 +++++++++++++++++ 6 files changed, 202 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..bacd46b25 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,47 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +/** + * packageName : com.loopers.domain.brand + * fileName : Brand + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Entity +@Table(name = "brand") +@Getter +public class Brand { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + protected Brand() {} + + private Brand(String name) { + this.name = requireValidName(name); + } + + public static Brand create(String name) { + return new Brand(name); + } + + + private String requireValidName(String name) { + if (name == null || name.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช… ๋น„์–ด ์žˆ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return name.trim(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..c558b23fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,20 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.brand + * fileName : BrandRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface BrandRepository { + Optional findById(Long id); + + void save(Brand brand); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..6aa724710 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,36 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.brand + * fileName : BrandService + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public void save(Brand brand) { + brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public Brand getBrand(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..111990a22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.brand + * fileName : BrandJpaRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface BrandJpaRepository extends JpaRepository { + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..f23e6e5d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.brand + * fileName : BrandRepositroyImpl + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository jpaRepository; + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public void save(Brand brand) { + jpaRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..9541c11f4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,42 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * packageName : com.loopers.domain.brand + * fileName : BrandTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class BrandTest { + + @DisplayName("Brand ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") + @Nested + class CreateBrandTest { + + @Test + @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ฑ ์„ฑ๊ณต") + void createBrandSuccess() { + Brand brand = Brand.create("Nike"); + assertThat(brand.getName()).isEqualTo("Nike"); + } + + @Test + @DisplayName("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ") + void createBrandFail() { + assertThatThrownBy(() -> Brand.create("")) + .isInstanceOf(CoreException.class); + } + } +} From 04ff3450f9994f9f4fd0906a9922d0e7136e867a Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 14 Nov 2025 16:51:05 +0900 Subject: [PATCH 052/164] =?UTF-8?q?feat(product):=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0/=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product ์—”ํ‹ฐํ‹ฐ ๊ตฌํ˜„ (์žฌ๊ณ , ์ข‹์•„์š” ์ˆ˜ ํฌํ•จ) - decreaseStock, increaseLikeCount ๋“ฑ ๋„๋ฉ”์ธ ๋กœ์ง ์ถ”๊ฐ€ - ProductRepository ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜ - Product ๋‹จ์œ„/ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ --- .../product/ProductDetailInfo.java | 32 +++++ .../application/product/ProductFacade.java | 47 +++++++ .../application/product/ProductInfo.java | 33 +++++ .../com/loopers/domain/product/Product.java | 117 ++++++++++++++++++ .../loopers/domain/product/ProductDetail.java | 45 +++++++ .../domain/product/ProductDomainService.java | 39 ++++++ .../domain/product/ProductRepository.java | 29 +++++ .../domain/product/ProductService.java | 39 ++++++ .../product/ProductJpaRepository.java | 19 +++ .../product/ProductRepositoryImpl.java | 55 ++++++++ .../ProductServiceIntegrationTest.java | 43 +++++++ .../loopers/domain/product/ProductTest.java | 95 ++++++++++++++ 12 files changed, 593 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..2a9ecee27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductDetail; + +/** + * packageName : com.loopers.application.product + * fileName : ProductDetail + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record ProductDetailInfo( + Long id, + String name, + String brandName, + Long price, + Long likeCount +) { + public static ProductDetailInfo from(ProductDetail productDetail) { + return new ProductDetailInfo( + productDetail.getId(), + productDetail.getName(), + productDetail.getBrandName(), + productDetail.getPrice(), + productDetail.getLikeCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..7b83360e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,47 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +/** + * packageName : com.loopers.application.product + * fileName : ProdcutFacade + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final LikeService likeService; + private final ProductDomainService productDomainService; + + public Page getProducts(Pageable pageable) { + return productService.getProducts(pageable) + .map(product -> { + Brand brand = brandService.getBrand(product.getId()); + long likeCount = likeService.countByProductId(product.getId()); + return ProductInfo.of(product, brand, likeCount); + }); + } + + public ProductDetailInfo getProduct(Long id) { + ProductDetail productDetail = productDomainService.getProductDetail(id); + return ProductDetailInfo.from(productDetail); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..8bcd93dd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +/** + * packageName : com.loopers.application.product + * fileName : ProductInfo + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record ProductInfo( + Long id, + String name, + String brandName, + Long price, + Long likeCount +) { + public static ProductInfo of(Product product, Brand brand, Long likeCount) { + return new ProductInfo( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice(), + likeCount + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..cade20580 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,117 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +/** + * packageName : com.loopers.domain.product + * fileName : Product + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Entity +@Table(name = "product") +@Getter +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_brand_id", nullable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Long price; + + @Column + private Long likeCount; + + @Column(nullable = false) + private Long stock; + + protected Product() {} + + private Product(Long brandId, String name, Long price, Long likeCount, Long stock) { + this.brandId = requireValidBrandId(brandId); + this.name = requireValidName(name); + this.price = requireValidPrice(price); + this.likeCount = requireValidLikeCount(likeCount); + this.stock = requireValidStock(stock); + } + + public static Product create(Long brandId, String name, Long price, Long stock) { + return new Product( + brandId, + name, + price, + 0L, + stock + ); + } + + private Long requireValidBrandId(Long brandId) { + if (brandId == null || brandId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + return brandId; + } + + private String requireValidName(String name) { + if (name == null || name.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return name; + } + + private Long requireValidPrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return price; + } + + public Long requireValidLikeCount(Long likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return likeCount; + } + + private Long requireValidStock(Long stock) { + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” 0 ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return stock; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) this.likeCount--; + } + + public void decreaseStock(Long quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (this.stock - quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.stock -= quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java new file mode 100644 index 000000000..808bff196 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java @@ -0,0 +1,45 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import lombok.Getter; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductDetail + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Getter +public class ProductDetail { + + private Long id; + private String name; + private String brandName; + private Long price; + private Long likeCount; + + protected ProductDetail() {} + + private ProductDetail(Long id, String name, String brandName, Long price, Long likeCount) { + this.id = id; + this.name = name; + this.brandName = brandName; + this.price = price; + this.likeCount = likeCount; + } + + public static ProductDetail of(Product product, Brand brand, Long likeCount) { + return new ProductDetail( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice(), + likeCount + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java new file mode 100644 index 000000000..f86edfddd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -0,0 +1,39 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductDetailService + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class ProductDomainService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + + public ProductDetail getProductDetail(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); + long likeCount = likeRepository.countByProductId(id); + + return ProductDetail.of(product, brand, likeCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..dadda62a0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,29 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductRepositroy + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface ProductRepository { + Page findAll(Pageable pageable); + + Optional findById(Long id); + + void incrementLikeCount(Long productId); + + void decrementLikeCount(Long productId); + + Product save(Product product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..a9c03fc80 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,39 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductService + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Component +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable) { + return productRepository.findAll(pageable); + } + + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค")); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..5ceaae067 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * packageName : com.loopers.infrastructure.product + * fileName : ProductJpaRepository + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface ProductJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..ba0feb19c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,55 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.product + * fileName : ProductRepositoryImpl + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public void incrementLikeCount(Long productId) { + Product product = productJpaRepository.findById(productId).get(); + product.increaseLikeCount(); + } + + @Override + public void decrementLikeCount(Long productId) { + Product product = productJpaRepository.findById(productId).get(); + product.decreaseLikeCount(); + } + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..8ad61a194 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.domain.product; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductServiceIntegrationTest + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@SpringBootTest +public class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ํ…Œ์ŠคํŠธ") + class ProductListTests { + + Product product; + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..c2c6fdd9b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,95 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductTest + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class ProductTest { + @DisplayName("Product ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ ํ…Œ์ŠคํŠธ") + @Nested + class LikeCountChange { + + @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค.") + @Test + void increaseLikeCount_incrementsLikeCount() { + // given + Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + // when + product.increaseLikeCount(); + + // then + assertEquals(1L, product.getLikeCount()); + } + + @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ๊ฐ์†Œ์‹œํ‚จ๋‹ค. 0 ๋ฏธ๋งŒ์œผ๋กœ๋Š” ๊ฐ์†Œํ•˜์ง€ ์•Š๋Š”๋‹ค.") + @Test + void decreaseLikeCount_decrementsLikeCountButNotBelowZero() { + // given + Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 1L); + + // when + product.decreaseLikeCount(); + + // then + assertEquals(0L, product.getLikeCount()); + + // when decrease again + product.decreaseLikeCount(); + + // then likeCount should not go below 0 + assertEquals(0L, product.getLikeCount()); + } + } + + @DisplayName("Product ์žฌ๊ณ  ์ฐจ๊ฐ ํ…Œ์ŠคํŠธ") + @Nested + class Stock { + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ •์ƒ ์ฐจ๊ฐํ•œ๋‹ค.") + @Test + void decreaseStock_successfullyDecreasesStock() { + // given + Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + // when + product.decreaseStock(3L); + + // then + assertEquals(7, product.getStock()); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void decreaseStock_withInvalidQuantity_throwsException() { + Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + assertThrows(CoreException.class, () -> product.decreaseStock(0L)); + assertThrows(CoreException.class, () -> product.decreaseStock(-1L)); + } + + @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ํฐ ์ˆ˜๋Ÿ‰ ์ฐจ๊ฐ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void decreaseStock_withInsufficientStock_throwsException() { + Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + assertThrows(CoreException.class, () -> product.decreaseStock(11L)); + } + } +} + From 7e0ac82a96f46a3b49837b5133167fae240e4e94 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 14 Nov 2025 16:51:42 +0900 Subject: [PATCH 053/164] =?UTF-8?q?feat(like):=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EC=B2=98=EB=A6=AC=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 - Like ์—”ํ‹ฐํ‹ฐ ๊ตฌํ˜„ - ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ๋กœ์ง ๋ฐ ์ค‘๋ณต ๋ฐฉ์ง€ (๋ฉฑ๋“ฑ์„ฑ) ๊ตฌํ˜„ - Product.likeCount ์ฆ๊ฐ€/๊ฐ์†Œ ์—ฐ๋™ - LikeRepository ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜ - Like ๋‹จ์œ„ /ํ†ต ํ•ฉ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ --- .../loopers/application/like/LikeFacade.java | 34 ++++ .../java/com/loopers/domain/like/Like.java | 63 +++++++ .../loopers/domain/like/LikeRepository.java | 25 +++ .../com/loopers/domain/like/LikeService.java | 49 ++++++ .../like/LikeJpaRepository.java | 23 +++ .../like/LikeRepositoryImpl.java | 46 ++++++ .../like/LikeServiceIntegrationTest.java | 155 ++++++++++++++++++ .../com/loopers/domain/like/LikeTest.java | 91 ++++++++++ 8 files changed, 486 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..5d885672e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,34 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.application.like + * fileName : LikeFacade + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +@Transactional +public class LikeFacade { + + private final LikeService likeService; + + public void createLike(String userId, Long productId) { + likeService.like(userId, productId); + } + + public void deleteLike(String userId, Long productId) { + likeService.unlike(userId, productId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..4430b496a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,63 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * packageName : com.loopers.domain.like + * fileName : Like + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Entity +@Table(name = "product_like") +@Getter +public class Like { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_user_id", nullable = false) + private String userId; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDateTime createdAt; + + protected Like() {} + + private Like(String userId, Long productId) { + this.userId = requireValidUserId(userId); + this.productId = requireValidProductId(productId); + this.createdAt = LocalDateTime.now(); + } + + public static Like create(String userId, Long productId) { + return new Like(userId, productId); + } + + private String requireValidUserId(String userId) { + if (userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return userId; + } + + private Long requireValidProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..945b10235 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,25 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.like + * fileName : LikeRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface LikeRepository { + + Optional findByUserIdAndProductId(String userId, Long productId); + + void save(Like like); + + void delete(Like like); + + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..41ae90b6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.application.like + * fileName : LikeService + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public void like(String userId, Long productId) { + if (likeRepository.findByUserIdAndProductId(userId, productId).isPresent()) return; + + Like like = Like.create(userId, productId); + likeRepository.save(like); + productRepository.incrementLikeCount(productId); + } + + @Transactional + public void unlike(String userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(like -> { + likeRepository.delete(like); + productRepository.decrementLikeCount(productId); + }); + } + + @Transactional(readOnly = true) + public long countByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..865a30db7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.like + * fileName : LikeJpaRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface LikeJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(String userId, Long productId); + + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..e037b6efb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.like + * fileName : LikeRepositoryImpl + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByUserIdAndProductId(String userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void save(Like like) { + likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..0be07a6fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.*; + +/** + * packageName : com.loopers.domain.like + * fileName : LikeServiceIntegrationTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@SpringBootTest +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp cleanUp; + + @AfterEach + void tearDown() { + cleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ข‹์•„์š” ๊ธฐ๋Šฅ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + class LikeTests { + + @Test + @DisplayName("์ข‹์•„์š” ์ƒ์„ฑ ์„ฑ๊ณต โ†’ ์ข‹์•„์š” ์ €์žฅ + ์ƒํ’ˆ์˜ likeCount ์ฆ๊ฐ€") + @Transactional + void likeSuccess() { + // given + User user = userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + Product product = productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + // when + likeService.like(user.getUserId(), product.getId()); + + // then + Like saved = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); + assertThat(saved).isNotNull(); + + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("์ค‘๋ณต ์ข‹์•„์š” ์‹œ likeCount ์ฆ๊ฐ€ ์•ˆ ํ•˜๊ณ  ์ €์žฅ๋„ ์•ˆ ๋จ") + @Transactional + void duplicateLike() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + likeService.like("user1", 1L); + + // when + likeService.like("user1", 1L); // ์ค‘๋ณต ํ˜ธ์ถœ + + // then + long likeCount = likeRepository.countByProductId(1L); + assertThat(likeCount).isEqualTo(1L); + + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(1L); // ์ฆ๊ฐ€ X + } + + @Test + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ์„ฑ๊ณต โ†’ like ์‚ญ์ œ + ์ƒํ’ˆ์˜ likeCount ๊ฐ์†Œ") + @Transactional + void unlikeSuccess() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + likeService.like("user1", 1L); + + // when + likeService.unlike("user1", 1L); + + // then + Like like = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); + assertThat(like).isNull(); + + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("์—†๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ likeCount ๊ฐ์†Œ ์•ˆ ํ•จ") + @Transactional + void unlikeNonExisting() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + Product product = Product.create(1L, "์ƒํ’ˆA", 1000L, 10L); + product.increaseLikeCount(); + product.increaseLikeCount(); + product.increaseLikeCount(); + product.increaseLikeCount(); + product.increaseLikeCount(); + + productRepository.save(product); + // when โ€” ํ˜ธ์ถœ์€ ํ•ด๋„ + likeService.unlike("user1", 1L); + + // then โ€” ๋ณ€ํ™” ์—†์Œ + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(5L); + } + + @Test + @DisplayName("countByProductId ์ •์ƒ ์กฐํšŒ") + @Transactional + void countTest() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + userRepository.save(new User("user2", "u2@mail.com", "1991-01-01", "MALE")); + productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + likeService.like("user1", 1L); + likeService.like("user2", 1L); + + // when + long count = likeService.countByProductId(1L); + + // then + assertThat(count).isEqualTo(2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..d5b8bd851 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * packageName : com.loopers.domain.like + * fileName : LikeTest + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class LikeTest { + + + @DisplayName("์ •์ƒ์ ์œผ๋กœ Like ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ƒ์„ฑ์ˆ˜ ํ•  ์žˆ๋‹ค") + @Nested + class LikeCreate { + + @DisplayName("Like์ƒ์„ฑ์ž๋ฅผ ์ƒ์„ฑํ•œ๋‹ค") + @Test + void createLike_success() { + // given + String userId = "user-001"; + Long productId = 100L; + + // when + Like like = Like.create(userId, productId); + + // then + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getCreatedAt()).isBeforeOrEqualTo(LocalDateTime.now()); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidUserId_null() { + // given + String userId = null; + Long productId = 100L; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + + @Test + @DisplayName("userId๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidUserId_empty() { + // given + String userId = ""; + Long productId = 100L; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + + @Test + @DisplayName("productId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidProductId_null() { + // given + String userId = "user-001"; + Long productId = null; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + + @Test + @DisplayName("productId๊ฐ€ 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidProductId_zeroOrNegative() { + // given + String userId = "user-001"; + Long productId = -1L; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + } +} From 1087ea25cc8da37b4a565b7131ab9418aabd157f Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 14 Nov 2025 16:59:26 +0900 Subject: [PATCH 054/164] =?UTF-8?q?feat(order,=20point):=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=EC=9C=A0=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B0=A8=EA=B0=90=20=EB=8F=84=EB=A9=94=EC=9D=B8=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 - Order / OrderItem ์—”ํ‹ฐํ‹ฐ ๊ตฌํ˜„ - ์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ, ํฌ์ธํŠธ ์ฐจ๊ฐ ๋„๋ฉ”์ธ ๋กœ์ง ์ถ”๊ฐ€ - Order ๋„๋ฉ”์ธ ์„œ๋น„์Šค + Facade ์กฐํ•ฉ ๊ตฌํ˜„ - OrderRepository, PointRepository ์ธํ„ฐํŽ˜์ด์Šค ์ •์˜ - ์ •์ƒ ์ฃผ๋ฌธ/์žฌ๊ณ ๋ถ€์กฑ ํฌ์ธํŠธ/๋ถ€์กฑ ๋‹จ์œ„ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ์ž‘์„ฑ --- .../application/order/CreateOrderCommand.java | 19 ++ .../application/order/OrderFacade.java | 83 +++++++++ .../loopers/application/order/OrderInfo.java | 42 +++++ .../application/order/OrderItemCommand.java | 17 ++ .../application/order/OrderItemInfo.java | 32 ++++ .../loopers/application/point/PointInfo.java | 2 +- .../java/com/loopers/domain/order/Order.java | 85 +++++++++ .../com/loopers/domain/order/OrderItem.java | 91 ++++++++++ .../loopers/domain/order/OrderRepository.java | 21 +++ .../loopers/domain/order/OrderService.java | 28 +++ .../com/loopers/domain/order/OrderStatus.java | 42 +++++ .../java/com/loopers/domain/point/Point.java | 31 +++- .../loopers/domain/point/PointService.java | 17 ++ .../order/OrderJpaRepository.java | 18 ++ .../order/OrderRepositoryImpl.java | 36 ++++ .../point/PointRepositoryImpl.java | 3 +- .../order/OrderServiceIntegrationTest.java | 170 ++++++++++++++++++ .../com/loopers/domain/order/OrderTest.java | 122 +++++++++++++ .../point/PointServiceIntegrationTest.java | 19 +- .../com/loopers/domain/point/PointTest.java | 111 ++++++++++-- .../api/point/PointV1ControllerTest.java | 4 +- 21 files changed, 969 insertions(+), 24 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java new file mode 100644 index 000000000..683e39cdd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -0,0 +1,19 @@ +package com.loopers.application.order; + +import java.util.List; + +/** + * packageName : com.loopers.application.order + * fileName : CreateOrderCommand + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record CreateOrderCommand( + String userId, + List items +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..9e06282b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,83 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.application.order + * fileName : OrderFacade + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final PointService pointService; + + @Transactional + public OrderInfo createOrder(CreateOrderCommand command) { + + if (command == null || command.items() == null || command.items().isEmpty()) { + throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ ์ •๋ณด๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"); + } + + Order order = Order.create(command.userId()); + + for (OrderItemCommand itemCommand : command.items()) { + + //์ƒํ’ˆ๊ฐ€์ ธ์˜ค๊ณ  + Product product = productService.getProduct(itemCommand.productId()); + + // ์žฌ๊ณ ๊ฐ์†Œ + product.decreaseStock(itemCommand.quantity()); + + // OrderItem์ƒ์„ฑ + OrderItem orderItem = OrderItem.create( + product.getId(), + product.getName(), + itemCommand.quantity(), + product.getPrice()); + + order.addOrderItem(orderItem); + orderItem.setOrder(order); + } + + //์ด ๊ฐ€๊ฒฉ๊ตฌํ•˜๊ณ  + long totalAmount = order.getOrderItems().stream() + .mapToLong(OrderItem::getAmount) + .sum(); + + order.updateTotalAmount(totalAmount); + + Point point = pointService.findPointByUserId(command.userId()); + point.use(totalAmount); + + //์ €์žฅ + Order saved = orderService.createOrder(order); + saved.updateStatus(OrderStatus.COMPLETE); + + return OrderInfo.from(saved); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..70028c27c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,42 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * packageName : com.loopers.application.order + * fileName : OrderInfo + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record OrderInfo( + Long orderId, + String userId, + Long totalAmount, + OrderStatus status, + LocalDateTime createdAt, + List items +) { + public static OrderInfo from(Order order) { + List itemInfos = order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList(); + + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalAmount(), + order.getStatus(), + order.getCreatedAt(), + itemInfos + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..1ac46862f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,17 @@ +package com.loopers.application.order; + +/** + * packageName : com.loopers.application.order + * fileName : OrderItemCommand + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record OrderItemCommand( + Long productId, + Long quantity +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..b3f2359c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +/** + * packageName : com.loopers.application.order + * fileName : OrderInfo + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record OrderItemInfo( + Long productId, + String productName, + Long quantity, + Long price, + Long amount +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPrice(), + item.getAmount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java index 2c357dc7e..65497297b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -6,7 +6,7 @@ public record PointInfo(String userId, Long amount) { public static PointInfo from(Point info) { return new PointInfo( info.getUserId(), - info.getAmount() + info.getBalance() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..9d7b9d3f2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,85 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * packageName : com.loopers.domain.order + * fileName : Order + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Entity +@Table(name = "orders") +@Getter +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_user_id", nullable = false) + private String userId; + + @Column(nullable = false) + private Long totalAmount; + + @Enumerated(EnumType.STRING) + private OrderStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderItems = new ArrayList<>(); + + protected Order() {} + + private Order(String userId, OrderStatus status) { + this.userId = requiredValidUserId(userId); + this.totalAmount = 0L; + this.status = requiredValidStatus(status); + this.createdAt = LocalDateTime.now(); + } + + public static Order create(String userId) { + return new Order(userId, OrderStatus.PENDING); + } + + public void addOrderItem(OrderItem orderItem) { + this.orderItems.add(orderItem); + } + + private OrderStatus requiredValidStatus(OrderStatus status) { + if (status == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ƒํƒœ๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); + } + return status; + } + + private String requiredValidUserId(String userId) { + if (userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); + } + return userId; + } + + public void updateTotalAmount(long totalAmount) { + this.totalAmount = totalAmount; + } + + public void updateStatus(OrderStatus status) { + this.status = status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..dce97a44a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,91 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderItem + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Entity +@Table(name = "order_item") +@Getter +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Setter + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + @Column(name = "ref_product_name", nullable = false) + private String productName; + + @Column(nullable = false) + private Long quantity; + + @Column(nullable = false) + private Long price; + + protected OrderItem() {} + + private OrderItem(Long productId, String productName, Long quantity, Long price) { + this.productId = requiredValidProductId(productId); + this.productName = requiredValidProductName(productName); + this.quantity = requiredQuantity(quantity); + this.price = requiredPrice(price); + } + + public static OrderItem create(Long productId, String productName, Long quantity, Long price) { + return new OrderItem(productId, productName, quantity, price); + } + + public Long getAmount() { + return quantity * price; + } + + private Long requiredValidProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return productId; + } + + private String requiredValidProductName(String productName) { + if (productName == null || productName.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return productName; + } + + private Long requiredQuantity(Long quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return quantity; + } + + private Long requiredPrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return price; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..c80262041 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderRepository + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..a66be03d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,28 @@ +package com.loopers.domain.order; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderService + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional + public Order createOrder(Order order) { + return orderRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..14ea592ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,42 @@ +package com.loopers.domain.order; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderStatus + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public enum OrderStatus { + + COMPLETE("๊ฒฐ์ œ์„ฑ๊ณต"), + CANCEL("๊ฒฐ์ œ์ทจ์†Œ"), + FAIL("๊ฒฐ์ œ์‹คํŒจ"), + PENDING("๊ฒฐ์ œ์ค‘"); + + private final String description; + + OrderStatus(String description) { + this.description = description; + } + + public boolean isCompleted() { + return this == COMPLETE; + } + + public boolean isPending() { + return this == PENDING; + } + + public boolean isCanceled() { + return this == CANCEL; + } + + public String description() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index b8f453f14..ea0c18c9d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -3,8 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; +import jakarta.persistence.*; import lombok.Getter; @Entity @@ -12,15 +11,23 @@ @Getter public class Point extends BaseEntity { + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + private String userId; - private Long amount; + private Long balance; protected Point() {} - public Point(String userId, Long amount) { + private Point(String userId, Long balance) { this.userId = requireValidUserId(userId); - this.amount = amount; + this.balance = balance; + } + + public static Point create(String userId, Long balance) { + return new Point(userId, balance); } String requireValidUserId(String userId) { @@ -34,7 +41,17 @@ public void charge(Long chargeAmount) { if (chargeAmount == null || chargeAmount <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } - this.amount += chargeAmount; - new Point(this.userId, this.amount); + this.balance += chargeAmount; + new Point(this.userId, this.balance); + } + + public void use(Long useAmount) { + if (useAmount == null || useAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (this.balance < useAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.balance -= useAmount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index dfc0788c6..1a6293f91 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -23,4 +23,21 @@ public Point chargePoint(String userId, Long chargeAmount) { point.charge(chargeAmount); return pointRepository.save(point); } + + @Transactional + public Point usePoint(String userId, Long useAmount) { + Point point = pointRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + if (useAmount == null || useAmount <= 0) { + throw new CoreException(ErrorType.NOT_FOUND, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + if (point.getBalance() < useAmount) { + throw new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + + point.use(useAmount); + return pointRepository.save(point); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..39cfb136d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * packageName : com.loopers.infrastructure.order + * fileName : OrderJpaRepository + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..f8c7b5b68 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.order + * fileName : OrderRepositroyImpl + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long orderId) { + return orderJpaRepository.findById(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java index 1663b1d4f..530191b66 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java @@ -20,7 +20,6 @@ public Optional findByUserId(String userId) { @Override public Point save(Point point) { - pointJpaRepository.save(point); - return point; + return pointJpaRepository.save(point); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..149e71540 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,170 @@ +package com.loopers.domain.order; + +import com.loopers.application.order.CreateOrderCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderServiceIntegrationTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@SpringBootTest +public class OrderServiceIntegrationTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private PointRepository pointRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") + class OrderCreateSuccess { + + @Test + @Transactional + void createOrder_success() { + + // given + Product p1 = productRepository.save(Product.create(1L, "์•„๋ฉ”๋ฆฌ์นด๋…ธ", 3000L, 100L)); + Product p2 = productRepository.save(Product.create(1L, "๋ผ๋–ผ", 4000L, 200L)); + + pointRepository.save(Point.create("user1", 20000L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of( + new OrderItemCommand(p1.getId(), 2L), // 6000์› + new OrderItemCommand(p2.getId(), 1L) // 4000์› + ) + ); + + // when + OrderInfo info = orderFacade.createOrder(command); + + // then + Order saved = orderRepository.findById(info.orderId()).orElseThrow(); + + assertThat(saved.getStatus()).isEqualTo(OrderStatus.COMPLETE); + assertThat(saved.getTotalAmount()).isEqualTo(10000L); + assertThat(saved.getOrderItems()).hasSize(2); + + // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ + Product updated1 = productRepository.findById(p1.getId()).get(); + Product updated2 = productRepository.findById(p2.getId()).get(); + assertThat(updated1.getStock()).isEqualTo(98); + assertThat(updated2.getStock()).isEqualTo(199); + + // ํฌ์ธํŠธ ๊ฐ์†Œ ํ™•์ธ + Point point = pointRepository.findByUserId("user1").get(); + assertThat(point.getBalance()).isEqualTo(10000L); // 20000 - 10000 + + } + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์‹คํŒจ ์ผ€์ด์Šค") + class OrderCreateFail { + + @Test + @Transactional + @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") + void insufficientStock_fail() { + Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 1L)); + pointRepository.save(Point.create("user1", 5000L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(item.getId(), 5L)) + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .isInstanceOf(RuntimeException.class); // ๋„ˆ์˜ ๋„๋ฉ”์ธ ์˜ˆ์™ธ ํƒ€์ž… ๋งž์ถฐ๋„ ๋จ + } + + @Test + @Transactional + @DisplayName("ํฌ์ธํŠธ ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") + void insufficientPoint_fail() { + Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); + pointRepository.save(Point.create("user1", 2000L)); // ๋ถ€์กฑ + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(item.getId(), 5L)) // ์ด 5000์› + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .hasMessageContaining("ํฌ์ธํŠธ"); // ๋ฉ”์‹œ์ง€ ๋งž์ถ”๋ฉด ๋” ์ •ํ™•ํ•˜๊ฒŒ ๊ฐ€๋Šฅ + } + + @Test + @Transactional + @DisplayName("์—†๋Š” ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ ์‹คํŒจ") + void noProduct_fail() { + pointRepository.save(Point.create("user1", 10000L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(999L, 1L)) + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @Transactional + @DisplayName("์œ ์ € ํฌ์ธํŠธ ์ •๋ณด ์—†์œผ๋ฉด ์‹คํŒจ") + void noUserPoint_fail() { + Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(item.getId(), 1L)) + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .isInstanceOf(RuntimeException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..60ed16ecc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,122 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class OrderTest { + + @Nested + @DisplayName("Order ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + class CreateOrderTest { + + @Test + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") + void createOrderSuccess() { + // when + Order order = Order.create("user123"); + + // then + assertThat(order.getUserId()).isEqualTo("user123"); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getTotalAmount()).isEqualTo(0L); + assertThat(order.getCreatedAt()).isNotNull(); + assertThat(order.getOrderItems()).isEmpty(); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createOrderFailUserIdNull() { + assertThatThrownBy(() -> Order.create(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); + } + + @Test + @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createOrderFailUserIdBlank() { + assertThatThrownBy(() -> Order.create("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); + } + } + + @Nested + @DisplayName("Order ์ƒํƒœ ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") + class UpdateStatusTest { + + @Test + @DisplayName("์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") + void updateStatusSuccess() { + // given + Order order = Order.create("user123"); + + // when + order.updateStatus(OrderStatus.COMPLETE); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETE); + } + } + + @Nested + @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") + class UpdateAmountTest { + + @Test + @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") + void updateTotalAmountSuccess() { + // given + Order order = Order.create("user123"); + + // when + order.updateTotalAmount(5000L); + + // then + assertThat(order.getTotalAmount()).isEqualTo(5000L); + } + } + + @Nested + @DisplayName("OrderItem ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ") + class AddOrderItemTest { + + @Test + @DisplayName("OrderItem ์ถ”๊ฐ€ ์„ฑ๊ณต") + void addOrderItemSuccess() { + // given + Order order = Order.create("user123"); + + OrderItem item = OrderItem.create( + 1L, + "์ƒํ’ˆ๋ช…", + 2L, + 1000L + ); + + // when + order.addOrderItem(item); + item.setOrder(order); + + // then + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getOrderItems().getFirst().getProductName()).isEqualTo("์ƒํ’ˆ๋ช…"); + assertThat(item.getOrder()).isEqualTo(order); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index 882bca673..b623bc9c7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -49,14 +49,14 @@ void returnPointInfo_whenValidIdIsProvided() { String gender = "MALE"; userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(new Point(id, 0L)); + pointRepository.save(Point.create(id, 0L)); //when Point result = pointService.findPointByUserId(id); //then assertThat(result.getUserId()).isEqualTo(id); - assertThat(result.getAmount()).isEqualTo(0L); + assertThat(result.getBalance()).isEqualTo(0L); } @DisplayName("ํšŒ์›์ด ์กด์žฌ ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") @@ -89,5 +89,20 @@ void throwsChargeAmountFailException_whenUserIDIsNotProvided() { //then assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } + + @Test + @DisplayName("ํšŒ์›์ด ์กด์žฌํ•˜๋ฉด ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") + void chargeSuccess() { + // given + String userId = "user2"; + userRepository.save(new User(userId, "yh45g@loopers.com", "1994-12-05", "MALE")); + pointRepository.save(Point.create(userId, 1000L)); + + // when + Point updated = pointService.chargePoint(userId, 500L); + + // then + assertThat(updated.getBalance()).isEqualTo(1500L); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java index 81bedab7d..f33fb2821 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java @@ -5,21 +5,112 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; class PointTest { - @DisplayName("Point ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") + + @Nested + @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + class CreatePointTest { + + @Test + @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ์„ฑ๊ณต") + void createPointSuccess() { + // when + Point point = Point.create("user123", 100L); + + // then + assertThat(point.getUserId()).isEqualTo("user123"); + assertThat(point.getBalance()).isEqualTo(100L); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createPointFailUserIdNull() { + assertThatThrownBy(() -> Point.create(null, 100L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createPointFailUserIdBlank() { + assertThatThrownBy(() -> Point.create("", 100L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + @Nested + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ…Œ์ŠคํŠธ") + class ChargePointTest { + + @Test + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") + void chargeSuccess() { + // given + Point point = Point.create("user123", 100L); + + // when + point.charge(50L); + + // then + assertThat(point.getBalance()).isEqualTo(150L); + } + + @Test + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") + void chargeFailZeroOrNegative() { + Point point = Point.create("user123", 100L); + + assertThatThrownBy(() -> point.charge(0L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ถฉ์ „"); + + assertThatThrownBy(() -> point.charge(-10L)) + .isInstanceOf(CoreException.class); + } + } + @Nested - class Charge { - @DisplayName("0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค.") + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ํ…Œ์ŠคํŠธ") + class UsePointTest { + + @Test + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์„ฑ๊ณต") + void useSuccess() { + // given + Point point = Point.create("user123", 100L); + + // when + point.use(40L); + + // then + assertThat(point.getBalance()).isEqualTo(60L); + } + + @Test + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") + void useFailZeroOrNegative() { + Point point = Point.create("user123", 100L); + + assertThatThrownBy(() -> point.use(0L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + + assertThatThrownBy(() -> point.use(-10L)) + .isInstanceOf(CoreException.class); + } + @Test - void throwsChargeAmountFailException_whenZeroAmountOrNegative() { - //given - Point point = new Point("yh45g", 0L); + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - ์ž”์•ก ๋ถ€์กฑ") + void useFailNotEnough() { + Point point = Point.create("user123", 50L); - //when&then - assertThrows(CoreException.class, () -> - point.charge(0L)); + assertThatThrownBy(() -> point.use(100L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑ"); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java index b725fd807..7d7a2c18c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java @@ -58,7 +58,7 @@ void returnPoint_whenValidUserIdIsProvided() { Long amount = 1000L; userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(new Point(id, amount)); + pointRepository.save(Point.create(id, amount)); HttpHeaders headers = new HttpHeaders(); headers.add("X-USER-ID", id); @@ -112,7 +112,7 @@ void returnsTotalPoint_whenChargeUserPoint() { String gender = "MALE"; userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(new Point(id, 0L)); + pointRepository.save(Point.create(id, 0L)); PointV1Dto.ChargePointRequest request = new PointV1Dto.ChargePointRequest(id, 1000L); From 3e2e0f447b5130cdd4f5db670811f828b544e1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 14 Nov 2025 08:53:14 +0900 Subject: [PATCH 055/164] =?UTF-8?q?round3:=20Product,=20Brand,=20Like,=20O?= =?UTF-8?q?rder=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/product/LikeProductFacade.java | 83 ++ .../like/product/LikeProductInfo.java | 11 + .../application/order/OrderFacade.java | 84 ++ .../loopers/application/order/OrderInfo.java | 21 + .../application/order/OrderItemInfo.java | 27 + .../application/product/ProductFacade.java | 72 ++ .../application/product/ProductInfo.java | 11 + .../java/com/loopers/domain/brand/Brand.java | 30 + .../loopers/domain/brand/BrandRepository.java | 10 + .../loopers/domain/brand/BrandService.java | 26 + .../com/loopers/domain/common/vo/Money.java | 17 + .../com/loopers/domain/common/vo/Price.java | 26 + .../domain/like/product/LikeProduct.java | 37 + .../like/product/LikeProductRepository.java | 16 + .../like/product/LikeProductService.java | 31 + .../metrics/product/ProductMetrics.java | 33 + .../product/ProductMetricsRepository.java | 11 + .../product/ProductMetricsService.java | 27 + .../java/com/loopers/domain/order/Order.java | 44 + .../com/loopers/domain/order/OrderItem.java | 50 ++ .../com/loopers/domain/order/OrderItem_b.java | 14 + .../loopers/domain/order/OrderRepository.java | 14 + .../loopers/domain/order/OrderService.java | 32 + .../java/com/loopers/domain/point/Point.java | 16 +- .../loopers/domain/point/PointService.java | 9 + .../com/loopers/domain/product/Product.java | 46 + .../domain/product/ProductRepository.java | 18 + .../domain/product/ProductService.java | 56 ++ .../com/loopers/domain/supply/Supply.java | 43 + .../domain/supply/SupplyRepository.java | 15 + .../loopers/domain/supply/SupplyService.java | 40 + .../com/loopers/domain/supply/vo/Stock.java | 40 + .../brand/BrandJpaRepository.java | 8 + .../brand/BrandRepositoryImpl.java | 25 + .../like/LikeProductJpaRepository.java | 17 + .../like/LikeProductRepositoryImpl.java | 37 + .../product/ProductMetricsJpaRepository.java | 10 + .../product/ProductMetricsRepositoryImpl.java | 25 + .../order/OrderJpaRepository.java | 16 + .../order/OrderRepositoryImpl.java | 31 + .../product/ProductJpaRepository.java | 7 + .../product/ProductRepositoryImpl.java | 38 + .../supply/SupplyJpaRepository.java | 21 + .../supply/SupplyRepositoryImpl.java | 36 + .../like/product/LikeProductV1ApiSpec.java | 49 ++ .../like/product/LikeProductV1Controller.java | 59 ++ .../api/like/product/LikeProductV1Dto.java | 48 + .../interfaces/api/order/OrderV1ApiSpec.java | 54 ++ .../api/order/OrderV1Controller.java | 60 ++ .../interfaces/api/order/OrderV1Dto.java | 73 ++ .../api/point/PointV1Controller.java | 2 +- .../api/product/ProductV1ApiSpec.java | 40 + .../api/product/ProductV1Controller.java | 49 ++ .../interfaces/api/product/ProductV1Dto.java | 46 + .../interfaces/api/user/UserV1Controller.java | 10 +- .../order/OrderFacadeIntegrationTest.java | 340 +++++++ .../com/loopers/domain/brand/BrandTest.java | 127 +++ .../loopers/domain/common/vo/PriceTest.java | 62 ++ .../LikeProductServiceIntegrationTest.java | 232 +++++ .../domain/like/product/LikeProductTest.java | 202 +++++ .../loopers/domain/order/OrderItemTest.java | 276 ++++++ .../order/OrderServiceIntegrationTest.java | 145 +++ .../com/loopers/domain/order/OrderTest.java | 176 ++++ .../ProductServiceIntegrationTest.java | 362 ++++++++ .../com/loopers/domain/supply/SupplyTest.java | 130 +++ .../loopers/domain/supply/vo/StockTest.java | 183 ++++ .../api/LikeProductV1ApiE2ETest.java | 485 ++++++++++ .../interfaces/api/OrderV1ApiE2ETest.java | 828 ++++++++++++++++++ .../interfaces/api/PointV1ApiE2ETest.java | 176 +++- .../interfaces/api/ProductV1ApiE2ETest.java | 265 ++++++ .../interfaces/api/UserV1ApiE2ETest.java | 72 +- 71 files changed, 5780 insertions(+), 52 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java new file mode 100644 index 000000000..c70fa18fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java @@ -0,0 +1,83 @@ +package com.loopers.application.like.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.product.LikeProduct; +import com.loopers.domain.like.product.LikeProductService; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.metrics.product.ProductMetricsService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.SupplyService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeProductFacade { + private final LikeProductService likeProductService; + private final UserService userService; + private final ProductService productService; + private final ProductMetricsService productMetricsService; + private final BrandService brandService; + private final SupplyService supplyService; + + public void likeProduct(String userId, Long productId) { + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + if (!productService.existsById(productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + likeProductService.likeProduct(user.getId(), productId); + } + + public void unlikeProduct(String userId, Long productId) { + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + if (!productService.existsById(productId)) { + throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + likeProductService.unlikeProduct(user.getId(), productId); + } + + public Page getLikedProducts(String userId, Pageable pageable) { + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Page likedProducts = likeProductService.getLikedProducts(user.getId(), pageable); + + List productIds = likedProducts.map(LikeProduct::getProductId).toList(); + Map productMap = productService.getProductMapByIds(productIds); + + Set brandIds = productMap.values().stream().map(Product::getBrandId).collect(Collectors.toSet()); + + Map metricsMap = productMetricsService.getMetricsMapByProductIds(productIds); + Map supplyMap = supplyService.getSupplyMapByProductIds(productIds); + Map brandMap = brandService.getBrandMapByBrandIds(brandIds); + + return likedProducts.map(likeProduct -> { + Product product = productMap.get(likeProduct.getProductId()); + ProductMetrics metrics = metricsMap.get(product.getId()); + Brand brand = brandMap.get(product.getBrandId()); + Supply supply = supplyMap.get(product.getId()); + + return new LikeProductInfo( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice().amount(), + metrics.getLikeCount(), + supply.getStock().quantity() + ); + }); + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java new file mode 100644 index 000000000..ce8b4928c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java @@ -0,0 +1,11 @@ +package com.loopers.application.like.product; + +public record LikeProductInfo( + Long id, + String name, + String brand, + int price, + int likes, + int stock +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..e4725ada1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,84 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.supply.SupplyService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + private final UserService userService; + private final OrderService orderService; + private final ProductService productService; + private final PointService pointService; + private final SupplyService supplyService; + + public OrderInfo getOrderInfo(String userId, Long orderId) { + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Order order = orderService.getOrderByIdAndUserId(orderId, user.getId()); + + return OrderInfo.from(order); + } + + @Transactional(readOnly = true) + public Page getOrderList(String userId, Pageable pageable) { + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Page orders = orderService.getOrdersByUserId(user.getId(), pageable); + return orders.map(OrderInfo::from); + } + + @Transactional + public OrderInfo createOrder(String userId, OrderV1Dto.OrderRequest request) { + User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + // request์—์„œ productId - quantity ๋งต ์ƒ์„ฑ + Map productQuantityMap = request.items().stream() + .collect(Collectors.toMap( + OrderV1Dto.OrderRequest.OrderItemRequest::productId, + OrderV1Dto.OrderRequest.OrderItemRequest::quantity + )); + + Map productMap = productService.getProductMapByIds(productQuantityMap.keySet()); + + request.items().forEach(item -> { + supplyService.checkAndDecreaseStock(item.productId(), item.quantity()); + }); + + Integer totalAmount = productService.calculateTotalAmount(productQuantityMap); + + pointService.checkAndDeductPoint(user.getId(), totalAmount); + + List orderItems = request.items() + .stream() + .map(item -> OrderItem.create( + item.productId(), + productMap.get(item.productId()).getName(), + item.quantity(), + productMap.get(item.productId()).getPrice() + )) + .toList(); + Order order = Order.create(user.getId(), orderItems); + + orderService.save(order); + + return OrderInfo.from(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..c75047e66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; + +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + Integer totalPrice, + List items +) { + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalPrice().amount(), + OrderItemInfo.fromList(order.getOrderItems()) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..99c53a78e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +import java.util.List; + +public record OrderItemInfo( + Long productId, + String productName, + Integer quantity, + Integer totalPrice +) { + public static OrderItemInfo from(OrderItem orderItem) { + return new OrderItemInfo( + orderItem.getProductId(), + orderItem.getProductName(), + orderItem.getQuantity(), + orderItem.getTotalPrice() + ); + } + + public static List fromList(List items) { + return items.stream() + .map(OrderItemInfo::from) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..e5feac116 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,72 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.metrics.product.ProductMetricsService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.SupplyService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + private final ProductService productService; + private final ProductMetricsService productMetricsService; + private final BrandService brandService; + private final SupplyService supplyService; + + @Transactional(readOnly = true) + public Page getProductList(Pageable pageable) { + Page products = productService.getProducts(pageable); + + List productIds = products.map(Product::getId).toList(); + Set brandIds = products.map(Product::getBrandId).toSet(); + + Map metricsMap = productMetricsService.getMetricsMapByProductIds(productIds); + Map supplyMap = supplyService.getSupplyMapByProductIds(productIds); + Map brandMap = brandService.getBrandMapByBrandIds(brandIds); + + return products.map(product -> { + ProductMetrics metrics = metricsMap.get(product.getId()); + Brand brand = brandMap.get(product.getBrandId()); + Supply supply = supplyMap.get(product.getId()); + + return new ProductInfo( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice().amount(), + metrics.getLikeCount(), + supply.getStock().quantity() + ); + }); + } + + @Transactional(readOnly = true) + public ProductInfo getProductDetail(Long productId) { + Product product = productService.getProductById(productId); + ProductMetrics metrics = productMetricsService.getMetricsByProductId(productId); + Brand brand = brandService.getBrandById(product.getBrandId()); + Supply supply = supplyService.getSupplyByProductId(productId); + + return new ProductInfo( + productId, + product.getName(), + brand.getName(), + product.getPrice().amount(), + metrics.getLikeCount(), + supply.getStock().quantity() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..b5286ed99 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,11 @@ +package com.loopers.application.product; + +public record ProductInfo( + Long id, + String name, + String brand, + int price, + int likes, + int stock +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..a55ccbd33 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,30 @@ + package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +@Entity +@Table(name = "tb_brand") +@Getter +public class Brand extends BaseEntity { + private String name; + + protected Brand() { + } + + private Brand(String name) { + this.name = name; + } + + public static Brand create(String name) { + if (StringUtils.isBlank(name)) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return new Brand(name); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..c51be9399 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.brand; + +import java.util.Collection; +import java.util.Optional; + +public interface BrandRepository { + Optional findById(Long id); + + Collection findAllByIdIn(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..3fba0f915 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,26 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Map; + +@RequiredArgsConstructor +@Component +public class BrandService { + private final BrandRepository brandRepository; + + public Brand getBrandById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + public Map getBrandMapByBrandIds(Collection brandIds) { + return brandRepository.findAllByIdIn(brandIds) + .stream() + .collect(java.util.stream.Collectors.toMap(Brand::getId, brand -> brand)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java new file mode 100644 index 000000000..8f3460701 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java @@ -0,0 +1,17 @@ +//package com.loopers.domain.common.vo; +// +//import com.loopers.support.error.CoreException; +//import com.loopers.support.error.ErrorType; +// +//public record Money(int amount) { +// public Money add(Money other) { +// return new Money(this.amount + other.amount); +// } +// +// public Money subtract(Money other) { +// // defensive programming: prevent negative money amounts +// if (this.amount < other.amount) { +// throw new CoreException(ErrorType.BAD_REQUEST, ) +// } +// } +//} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java new file mode 100644 index 000000000..58eee0043 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java @@ -0,0 +1,26 @@ +package com.loopers.domain.common.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeConverter; + +public record Price(int amount) { + public Price { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + public static class Converter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(Price attribute) { + return attribute.amount(); + } + + @Override + public Price convertToEntityAttribute(Integer dbData) { + return new Price(dbData); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java new file mode 100644 index 000000000..ee8d09c49 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java @@ -0,0 +1,37 @@ +package com.loopers.domain.like.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "tb_like_product") +@Getter +public class LikeProduct extends BaseEntity { + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + @Column(name = "product_id", nullable = false, updatable = false) + private Long productId; + + protected LikeProduct() { + } + + private LikeProduct(Long userId, Long productId) { + this.userId = userId; + this.productId = productId; + } + + public static LikeProduct create(Long userId, Long productId) { + if (userId == null || userId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return new LikeProduct(userId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java new file mode 100644 index 000000000..525176f10 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.like.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface LikeProductRepository { + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + void save(LikeProduct likeProduct); + + Page getLikeProductsByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java new file mode 100644 index 000000000..f9cacef65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.like.product; + +import com.loopers.domain.BaseEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class LikeProductService { + private final LikeProductRepository likeProductRepository; + + public void likeProduct(Long userId, Long productId) { + likeProductRepository.findByUserIdAndProductId(userId, productId) + .ifPresentOrElse(BaseEntity::restore, () -> { + LikeProduct likeProduct = LikeProduct.create(userId, productId); + likeProductRepository.save(likeProduct); + }); + } + + public void unlikeProduct(Long userId, Long productId) { + likeProductRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(BaseEntity::delete); + } + + public Page getLikedProducts(Long userId, Pageable pageable) { + return likeProductRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java new file mode 100644 index 000000000..d815fd878 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java @@ -0,0 +1,33 @@ +package com.loopers.domain.metrics.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "tb_product_metrics") +@Getter +public class ProductMetrics extends BaseEntity { + // ํ˜„์žฌ๋Š” ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋งŒ ๊ด€๋ฆฌํ•˜์ง€๋งŒ, ์ถ”ํ›„์— ๋‹ค๋ฅธ ๋ฉ”ํŠธ๋ฆญ๋“ค๋„ ์ถ”๊ฐ€๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + private Long productId; + private Integer likeCount; + + protected ProductMetrics() { + } + + public static ProductMetrics create(Long productId, Integer likeCount) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ์ˆ˜๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + ProductMetrics metrics = new ProductMetrics(); + metrics.productId = productId; + metrics.likeCount = likeCount; + return metrics; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java new file mode 100644 index 000000000..64b8d5c75 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.metrics.product; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductMetricsRepository { + Optional findByProductId(Long productId); + + Collection findByProductIds(Collection productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java new file mode 100644 index 000000000..4975f177c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.metrics.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductMetricsService { + private final ProductMetricsRepository productMetricsRepository; + + public ProductMetrics getMetricsByProductId(Long productId) { + return productMetricsRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ฉ”ํŠธ๋ฆญ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + public Map getMetricsMapByProductIds(Collection productIds) { + return productMetricsRepository.findByProductIds(productIds) + .stream() + .collect(Collectors.toMap(ProductMetrics::getProductId, metrics -> metrics)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..c9af5dba3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,44 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.vo.Price; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.util.List; + +@Entity +@Table(name = "tb_order") +@Getter +public class Order extends BaseEntity { + private Long userId; + @ElementCollection + @CollectionTable( + name = "tb_order_item", + joinColumns = @JoinColumn(name = "order_id") + ) + private List orderItems; + @Convert(converter = Price.Converter.class) + private Price totalPrice; + + protected Order() { + } + + private Order(Long userId, List orderItems) { + this.userId = userId; + this.orderItems = orderItems; + this.totalPrice = new Price(orderItems.stream().map(OrderItem::getTotalPrice).reduce(Math::addExact).get()); + } + + public static Order create(Long userId, List orderItems) { + if (userId == null || userId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (orderItems == null || orderItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return new Order(userId, orderItems); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..e4a4eaa3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,50 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.vo.Price; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Convert; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +@Embeddable +@Getter +public class OrderItem { + private Long productId; + private String productName; + private Integer quantity; + @Convert(converter = Price.Converter.class) + private Price price; + + public Integer getTotalPrice() { + return this.price.amount() * this.quantity; + } + + protected OrderItem() { + } + + private OrderItem(Long productId, String productName, Integer quantity, Price price) { + this.productId = productId; + this.productName = productName; + this.quantity = quantity; + this.price = price; + } + + public static OrderItem create(Long productId, String productName, Integer quantity, Price price) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (StringUtils.isBlank(productName)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + return new OrderItem(productId, productName, quantity, price); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java new file mode 100644 index 000000000..ca3d76b1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java @@ -0,0 +1,14 @@ +//package com.loopers.domain.order; +// +//import com.loopers.domain.common.vo.Price; +// +//public record OrderItem( +// Long productId, +// String productName, +// Integer quantity, +// Price price +//) { +// public Integer getTotalPrice() { +// return this.price.amount() * this.quantity; +// } +//} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..0118aa719 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface OrderRepository{ + Optional findByIdAndUserId(Long id, Long userId); + + Order save(Order order); + + Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..7628720f4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,32 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderService { + private final OrderRepository orderRepository; + + public Order save(Order order) { + return orderRepository.save(order); + } + + public Order getOrderByIdAndUserId(Long orderId, Long userId) { + return orderRepository.findByIdAndUserId(orderId, userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + public Page getOrdersByUserId(Long userId, Pageable pageable) { + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + return orderRepository.findByUserIdAndDeletedAtIsNull(userId, PageRequest.of(page, size, sort)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index dbf1bcd61..ebe3d964b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -28,11 +28,21 @@ public static Point create(Long userId) { return new Point(userId, 0L); } - public void charge(int amount) { - if (amount <= 0) { + public void charge(int otherAmount) { + if (otherAmount <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } - this.amount += amount; + this.amount += otherAmount; + } + + public void deduct(int otherAmount) { + if (otherAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (this.amount < otherAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.amount -= otherAmount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index f557f79e8..2ea51a376 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -32,4 +32,13 @@ public Long chargePoint(Long userId, int amount) { public Optional getCurrentPoint(Long userId) { return pointRepository.findByUserId(userId).map(Point::getAmount); } + + @Transactional + public void checkAndDeductPoint(Long userId, Integer totalAmount) { + Point point = pointRepository.findByUserId(userId).orElseThrow( + () -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") + ); + point.deduct(totalAmount); + pointRepository.save(point); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..250516420 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,46 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.vo.Price; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +@Entity +@Table(name = "tb_product") +@Getter +public class Product extends BaseEntity { + protected Product() { + } + + private String name; + @Column(name = "brand_id", nullable = false, updatable = false) + private Long brandId; + @Convert(converter = Price.Converter.class) + private Price price; + + public static Product create(String name, Long brandId, Price price) { + if (StringUtils.isBlank(name)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (brandId == null || brandId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (price.amount() < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + Product product = new Product(); + product.name = name; + product.brandId = brandId; + product.price = price; + return product; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..beb141147 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + Optional findById(Long productId); + + Page findAll(Pageable pageable); + + List findAllByIdIn(Collection ids); + + boolean existsById(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..38fe96076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,56 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductService { + private final ProductRepository productRepository; + + public Product getProductById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + public Map getProductMapByIds(Collection productIds) { + return productRepository.findAllByIdIn(productIds) + .stream() + .collect(Collectors.toMap(Product::getId, product -> product)); + } + + public Page getProducts(Pageable pageable) { + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + String sortStr = pageable.getSort().toString().split(":")[0]; + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + if (StringUtils.startsWith(sortStr, "price_asc")) { + sort = Sort.by(Sort.Direction.ASC, "price"); + } else if (StringUtils.equals(sortStr, "like_desc")) { + sort = Sort.by(Sort.Direction.DESC, "like_count"); + } + return productRepository.findAll(PageRequest.of(page, size, sort)); + } + + public Integer calculateTotalAmount(Map items) { + return productRepository.findAllByIdIn(items.keySet()) + .stream() + .mapToInt(product -> product.getPrice().amount() * items.get(product.getId())) + .sum(); + } + + public boolean existsById(Long productId) { + return productRepository.existsById(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java new file mode 100644 index 000000000..834bd4628 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java @@ -0,0 +1,43 @@ +package com.loopers.domain.supply; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.supply.vo.Stock; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Table(name = "tb_supply") +@Getter +public class Supply extends BaseEntity { + private Long productId; + @Setter + @Convert(converter = Stock.Converter.class) + private Stock stock; + // think: ์ธ๋‹น ๊ตฌ๋งค์ œํ•œ? + + protected Supply() { + } + + public static Supply create(Long productId, Stock stock) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + Supply supply = new Supply(); + supply.productId = productId; + supply.stock = stock; + return supply; + } + + // decreaseStock, increaseStock + public void decreaseStock(int quantity) { + this.stock = this.stock.decrease(quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java new file mode 100644 index 000000000..7e7cb09ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.supply; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface SupplyRepository { + Optional findByProductId(Long productId); + + List findAllByProductIdIn(Collection productIds); + + Optional findByProductIdForUpdate(Long productId); + + Supply save(Supply supply); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java new file mode 100644 index 000000000..b8de2c1df --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java @@ -0,0 +1,40 @@ +package com.loopers.domain.supply; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class SupplyService { + private final SupplyRepository supplyRepository; + + public Supply getSupplyByProductId(Long productId) { + return supplyRepository.findByProductId(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } + + public Map getSupplyMapByProductIds(Collection productIds) { + return supplyRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(Supply::getProductId, supply -> supply)); + } + + @Transactional + public void checkAndDecreaseStock(Long productId, Integer quantity) { + Supply supply = supplyRepository.findByProductIdForUpdate(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + supply.decreaseStock(quantity); + supplyRepository.save(supply); + } + + public Supply saveSupply(Supply supply) { + return supplyRepository.save(supply); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java new file mode 100644 index 000000000..b76e5f1a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java @@ -0,0 +1,40 @@ +package com.loopers.domain.supply.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeConverter; + +public record Stock(int quantity) { + public Stock { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + public boolean isOutOfStock() { + return this.quantity <= 0; + } + + public Stock decrease(int orderQuantity) { + if (orderQuantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (orderQuantity > this.quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + return new Stock(this.quantity - orderQuantity); + } + + public static class Converter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(Stock attribute) { + return attribute.quantity(); + } + + @Override + public Stock convertToEntityAttribute(Integer dbData) { + return new Stock(dbData); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..aa99ac6ca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..69b7cbb79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + private final BrandJpaRepository brandJpaRepository; + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } + + @Override + public Collection findAllByIdIn(Collection ids) { + return brandJpaRepository.findAllById(ids); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java new file mode 100644 index 000000000..6c247ec44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.product.LikeProduct; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface LikeProductJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(Long userId, Long productId); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Page getLikeProductsByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java new file mode 100644 index 000000000..8827a431f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.product.LikeProduct; +import com.loopers.domain.like.product.LikeProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeProductRepositoryImpl implements LikeProductRepository { + private final LikeProductJpaRepository likeProductJpaRepository; + + @Override + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return likeProductJpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeProductJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void save(LikeProduct likeProduct) { + likeProductJpaRepository.save(likeProduct); + } + + @Override + public Page getLikeProductsByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable) { + return likeProductJpaRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, pageable); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..42bde9788 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.metrics.product; + +import com.loopers.domain.metrics.product.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProductMetricsJpaRepository extends JpaRepository { + Optional findByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java new file mode 100644 index 000000000..db76a1d92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.metrics.product; + +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.metrics.product.ProductMetricsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { + private final ProductMetricsJpaRepository jpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return jpaRepository.findByProductId(productId); + } + + @Override + public Collection findByProductIds(Collection productIds) { + return jpaRepository.findAllById(productIds); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..c3337045e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + Optional findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId); + + Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..67a7462fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + private final OrderJpaRepository orderJpaRepository; + + @Override + public Optional findByIdAndUserId(Long id, Long userId) { + return orderJpaRepository.findByIdAndUserIdAndDeletedAtIsNull(id, userId); + } + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable) { + return orderJpaRepository.findByUserIdAndDeletedAtIsNull(userId, pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..0375b7ca7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..b2b1115b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long productId) { + return productJpaRepository.findById(productId); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public List findAllByIdIn(Collection ids) { + return productJpaRepository.findAllById(ids); + } + + @Override + public boolean existsById(Long productId) { + return productJpaRepository.existsById(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java new file mode 100644 index 000000000..ec66a450d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.supply; + +import com.loopers.domain.supply.Supply; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface SupplyJpaRepository extends JpaRepository { + Optional findByProductId(Long productId); + + List findAllByProductIdIn(Collection productIds); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Supply s WHERE s.productId = :productId") + Optional findByProductIdForUpdate(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java new file mode 100644 index 000000000..92b13a07b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.supply; + +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.SupplyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class SupplyRepositoryImpl implements SupplyRepository { + private final SupplyJpaRepository supplyJpaRepository; + + @Override + public Optional findByProductId(Long productId) { + return supplyJpaRepository.findByProductId(productId); + } + + @Override + public List findAllByProductIdIn(Collection productIds) { + return supplyJpaRepository.findAllByProductIdIn(productIds); + } + + @Override + public Optional findByProductIdForUpdate(Long productId) { + return supplyJpaRepository.findByProductIdForUpdate(productId); + } + + @Override + public Supply save(Supply supply) { + return supplyJpaRepository.save(supply); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java new file mode 100644 index 000000000..716f9b735 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.like.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Like Product V1 API", description = "์ƒํ’ˆ ์ข‹์•„์š” API ์ž…๋‹ˆ๋‹ค.") +public interface LikeProductV1ApiSpec { + // /api/v1/like/products/{productId} - POST + @Operation( + method = "POST", + summary = "์ƒํ’ˆ ์ข‹์•„์š” ์ถ”๊ฐ€", + description = "ํšŒ์›์ด ํŠน์ • ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse likeProduct( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + Long productId + ); + + // /api/v1/like/products/{productId} - DELETE + @Operation( + method = "DELETE", + summary = "์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ", + description = "ํšŒ์›์ด ํŠน์ • ์ƒํ’ˆ์— ๋Œ€ํ•œ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse unlikeProduct( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + Long productId + ); + + // /api/v1/like/products - GET + @Operation( + method = "GET", + summary = "ํšŒ์›์ด ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", + description = "ํšŒ์›์ด ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ๋“ค์˜ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getLikedProducts( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @Schema( + name = "ํŽ˜์ด์ง€ ์ •๋ณด", + description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ, ํŽ˜์ด์ง€ ํฌ๊ธฐ, ์ •๋ ฌ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ํŽ˜์ด์ง€ ์ •๋ณด" + + "\n- ๊ธฐ๋ณธ๊ฐ’: page=0, size=20" + ) + Pageable pageable + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java new file mode 100644 index 000000000..f49e584d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.like.product; + +import com.loopers.application.like.product.LikeProductFacade; +import com.loopers.application.like.product.LikeProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/like/products") +public class LikeProductV1Controller implements LikeProductV1ApiSpec { + private final LikeProductFacade likeProductFacade; + + @RequestMapping(method = RequestMethod.POST, path = "/{productId}") + @Override + public ApiResponse likeProduct( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @PathVariable Long productId) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + likeProductFacade.likeProduct(userId, productId); + return ApiResponse.success(null); + } + + @RequestMapping(method = RequestMethod.DELETE, path = "/{productId}") + @Override + public ApiResponse unlikeProduct( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @PathVariable Long productId + ) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + likeProductFacade.unlikeProduct(userId, productId); + return ApiResponse.success(null); + } + + @RequestMapping(method = RequestMethod.GET) + @Override + public ApiResponse getLikedProducts( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @PageableDefault(size = 20) Pageable pageable + ) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + Page likedProducts = likeProductFacade.getLikedProducts(userId, pageable); + LikeProductV1Dto.ProductsResponse response = LikeProductV1Dto.ProductsResponse.from(likedProducts); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java new file mode 100644 index 000000000..81c084b56 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.like.product; + +import com.loopers.application.like.product.LikeProductInfo; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; + +import java.util.List; + +public class LikeProductV1Dto { + public record ProductResponse( + Long id, + String name, + String brand, + int price, + int likes, + int stock + ) { + public static LikeProductV1Dto.ProductResponse from(LikeProductInfo info) { + return new LikeProductV1Dto.ProductResponse( + info.id(), + info.name(), + info.brand(), + info.price(), + info.likes(), + info.stock() + ); + } + } + + public record ProductsResponse( + List content, + int totalPages, + long totalElements, + int number, + int size + + ) { + public static LikeProductV1Dto.ProductsResponse from(Page page) { + return new LikeProductV1Dto.ProductsResponse( + page.map(LikeProductV1Dto.ProductResponse::from).getContent(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumber(), + page.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..57197cb68 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,54 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Order V1 API", description = "์ฃผ๋ฌธ API ์ž…๋‹ˆ๋‹ค.") +public interface OrderV1ApiSpec { + // /api/v1/orders - POST + @Operation( + method = "POST", + summary = "์ฃผ๋ฌธ ์ƒ์„ฑ", + description = "์ƒˆ๋กœ์šด ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse createOrder( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @Schema( + name = "์ฃผ๋ฌธ ์š”์ฒญ ์ •๋ณด", + description = "์ฃผ๋ฌธ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด" + ) + OrderV1Dto.OrderRequest request + ); + + // /api/v1/orders - GET + @Operation( + method = "GET", + summary = "์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ", + description = "ํšŒ์›์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getOrderList( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @PageableDefault(size = 20) Pageable pageable + ); + + // /api/v1/orders/{orderId} - GET + @Operation( + method = "GET", + summary = "์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ", + description = "ํŠน์ • ์ฃผ๋ฌธ์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getOrderDetail( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @Schema( + name = "์ฃผ๋ฌธ ID", + description = "์กฐํšŒํ•  ์ฃผ๋ฌธ์˜ ID" + ) + Long orderId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..ae8f75ae3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + private final OrderFacade orderFacade; + + @RequestMapping(method = RequestMethod.POST) + @Override + public ApiResponse createOrder( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @RequestBody OrderV1Dto.OrderRequest request + ) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + OrderInfo orderInfo = orderFacade.createOrder(userId, request); + OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(orderInfo); + return ApiResponse.success(response); + } + + @RequestMapping(method = RequestMethod.GET) + @Override + public ApiResponse getOrderList( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @PageableDefault(size = 20) Pageable pageable) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + Page orderInfos = orderFacade.getOrderList(userId, pageable); + OrderV1Dto.OrderPageResponse response = OrderV1Dto.OrderPageResponse.from(orderInfos); + return ApiResponse.success(response); + } + + @RequestMapping(method = RequestMethod.GET, path = "/{orderId}") + @Override + public ApiResponse getOrderDetail( + @RequestHeader(value = "X-USER-ID", required = false) String userId, + @PathVariable Long orderId) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + OrderInfo orderInfo = orderFacade.getOrderInfo(userId, orderId); + OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(orderInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..01b97d12e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,73 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import org.springframework.data.domain.Page; + +import java.util.List; + +public class OrderV1Dto { + public record OrderRequest( + List items + ) { + public record OrderItemRequest( + Long productId, + Integer quantity + ) { + } + } + + public record OrderResponse( + Long orderId, + List items, + Integer totalPrice + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.orderId(), + OrderItem.fromList(info.items()), + info.totalPrice() + ); + } + } + + public record OrderItem( + Long productId, + String productName, + Integer quantity, + Integer totalPrice + ) { + public static OrderItem from(OrderItemInfo info) { + return new OrderItem( + info.productId(), + info.productName(), + info.quantity(), + info.totalPrice() + ); + } + + public static List fromList(List infos) { + return infos.stream() + .map(OrderItem::from) + .toList(); + } + } + + public record OrderPageResponse( + List content, + int totalPages, + long totalElements, + int number, + int size + ) { + public static OrderPageResponse from(Page page) { + return new OrderPageResponse( + page.map(OrderResponse::from).getContent(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumber(), + page.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index 285e70a04..c656f69d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -26,7 +26,7 @@ public ApiResponse getUserPoints(@RequestHeader(value return ApiResponse.success(response); } - @RequestMapping(method = RequestMethod.POST) + @RequestMapping(method = RequestMethod.POST, path = "/charge") @Override public ApiResponse chargeUserPoints( @RequestHeader(value = "X-USER-ID", required = false) String userId, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..5217803a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "์ƒํ’ˆ API ์ž…๋‹ˆ๋‹ค.") +public interface ProductV1ApiSpec { + // /api/v1/products - GET + @Operation( + method = "GET", + summary = "์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", + description = "์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getProductList( + @Schema( + name = "ํŽ˜์ด์ง€ ์ •๋ณด", + description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ, ํŽ˜์ด์ง€ ํฌ๊ธฐ, ์ •๋ ฌ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ํŽ˜์ด์ง€ ์ •๋ณด" + + "\n- sort ์˜ต์…˜: latest (์ตœ์‹ ์ˆœ), price_asc (๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ), like_desc (์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ)" + + "\n- ๊ธฐ๋ณธ๊ฐ’: page=0, size=20, sort=latest" + ) + Pageable pageable + ); + + // /api/v1/products/{productId} - GET + @Operation( + method = "GET", + summary = "์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ", + description = "์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getProductDetail( + @Schema( + name = "์ƒํ’ˆ ID", + description = "์กฐํšŒํ•  ์ƒํ’ˆ์˜ ID" + ) + Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..9963fca1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + private final ProductFacade productFacade; + + @RequestMapping(method = RequestMethod.GET) + @Override + public ApiResponse getProductList(@PageableDefault(size = 20) Pageable pageable) { + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + String sortStr = pageable.getSort().toString().split(":")[0]; + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + if (StringUtils.equals(sortStr, "price_asc")) { + sort = Sort.by(Sort.Direction.ASC, "price"); + } else if (StringUtils.equals(sortStr, "like_desc")) { + sort = Sort.by(Sort.Direction.DESC, "like_count"); + } + + Page products = productFacade.getProductList(PageRequest.of(page, size, sort)); + ProductV1Dto.ProductsPageResponse response = ProductV1Dto.ProductsPageResponse.from(products); + return ApiResponse.success(response); + } + + @RequestMapping(method = RequestMethod.GET, path = "/{productId}") + @Override + public ApiResponse getProductDetail(@PathVariable Long productId) { + ProductInfo info = productFacade.getProductDetail(productId); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..29f957cf5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import org.springframework.data.domain.Page; + +import java.util.List; + +public class ProductV1Dto { + public record ProductResponse( + Long id, + String name, + String brand, + int price, + int likes, + int stock + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.brand(), + info.price(), + info.likes(), + info.stock() + ); + } + } + + public record ProductsPageResponse( + List content, + int totalPages, + long totalElements, + int number, + int size + ) { + public static ProductsPageResponse from(Page page) { + return new ProductsPageResponse( + page.map(ProductResponse::from).getContent(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumber(), + page.getSize() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 059faf73f..e61ec5290 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -3,7 +3,10 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.web.bind.annotation.*; @RequiredArgsConstructor @@ -25,9 +28,12 @@ public ApiResponse registerUser(@RequestBody UserV1Dto.U return ApiResponse.success(response); } - @RequestMapping(method = RequestMethod.GET, path = "/{userId}") + @RequestMapping(method = RequestMethod.GET, path = "/me") @Override - public ApiResponse getUserInfo(@PathVariable String userId) { + public ApiResponse getUserInfo(@RequestHeader(value = "X-USER-ID", required = false) String userId) { + if (StringUtils.isBlank(userId)) { + throw new CoreException(ErrorType.BAD_REQUEST); + } UserInfo info = userFacade.getUserInfo(userId); UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); return ApiResponse.success(response); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java new file mode 100644 index 000000000..7adbb731d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -0,0 +1,340 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.vo.Price; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.point.Point; +import com.loopers.domain.product.Product; +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.vo.Stock; +import com.loopers.domain.user.User; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; +import com.loopers.infrastructure.point.PointJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.supply.SupplyJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@Transactional +@DisplayName("์ฃผ๋ฌธ Facade(OrderFacade) ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") +public class OrderFacadeIntegrationTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private PointJpaRepository pointJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private SupplyJpaRepository supplyJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private String userId; + private Long userEntityId; + private Long brandId; + private Long productId1; + private Long productId2; + private Long productId3; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @BeforeEach + void setup() { + // User ๋“ฑ๋ก + User user = User.create("user123", "test@test.com", "1993-03-13", "male"); + User savedUser = userJpaRepository.save(user); + userId = savedUser.getUserId(); + userEntityId = savedUser.getId(); + + // Point ๋“ฑ๋ก ๋ฐ ์ถฉ์ „ + Point point = Point.create(userEntityId); + point.charge(100000); + pointJpaRepository.save(point); + + // Brand ๋“ฑ๋ก + Brand brand = Brand.create("Nike"); + Brand savedBrand = brandJpaRepository.save(brand); + brandId = savedBrand.getId(); + + // Product ๋“ฑ๋ก + Product product1 = Product.create("์ƒํ’ˆ1", brandId, new Price(10000)); + Product savedProduct1 = productJpaRepository.save(product1); + productId1 = savedProduct1.getId(); + ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); + productMetricsJpaRepository.save(metrics1); + + Product product2 = Product.create("์ƒํ’ˆ2", brandId, new Price(20000)); + Product savedProduct2 = productJpaRepository.save(product2); + productId2 = savedProduct2.getId(); + ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); + productMetricsJpaRepository.save(metrics2); + + Product product3 = Product.create("์ƒํ’ˆ3", brandId, new Price(15000)); + Product savedProduct3 = productJpaRepository.save(product3); + productId3 = savedProduct3.getId(); + ProductMetrics metrics3 = ProductMetrics.create(productId3, 0); + productMetricsJpaRepository.save(metrics3); + + // Supply ๋“ฑ๋ก (์žฌ๊ณ  ์„ค์ •) + Supply supply1 = Supply.create(productId1, new Stock(100)); + supplyJpaRepository.save(supply1); + + Supply supply2 = Supply.create(productId2, new Stock(50)); + supplyJpaRepository.save(supply2); + + Supply supply3 = Supply.create(productId3, new Stock(30)); + supplyJpaRepository.save(supply3); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œ, ") + @Nested + class CreateOrder { + @DisplayName("์ •์ƒ์ ์ธ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createOrder_when_validRequest() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 1) + ) + ); + + // act + OrderInfo orderInfo = orderFacade.createOrder(userId, request); + + // assert + assertThat(orderInfo).isNotNull(); + assertThat(orderInfo.orderId()).isNotNull(); + assertThat(orderInfo.items()).hasSize(2); + assertThat(orderInfo.totalPrice()).isEqualTo(40000); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdDoesNotExist() { + // arrange + Long nonExistentProductId = 99999L; + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(nonExistentProductId, 1) + ) + ); + + // act & assert + // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” productMap.get()์ด null์„ ๋ฐ˜ํ™˜ํ•˜์—ฌ NullPointerException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ + // ๋˜๋Š” SupplyService.checkAndDecreaseStock์—์„œ NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ + assertThrows(Exception.class, () -> orderFacade.createOrder(userId, request)); + } + + @DisplayName("๋‹จ์ผ ์ƒํ’ˆ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_singleProductStockInsufficient() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999) + ) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + } + + @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ค‘ ์ผ๋ถ€๋งŒ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_partialStockInsufficient() { + // arrange + // productId1: ์žฌ๊ณ  100, productId2: ์žฌ๊ณ  50 + // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ + ) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ + // ๊ฐœ์„  ํ›„์—๋Š” ๋ชจ๋“  ๋ถ€์กฑํ•œ ์ƒํ’ˆ์„ ํ•œ ๋ฒˆ์— ์•Œ๋ ค์ค„ ์ˆ˜ ์žˆ์Œ + } + + @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ชจ๋‘ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_allProductsStockInsufficient() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999), + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) + ) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ + } + + @DisplayName("Supply ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†๋Š” ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_supplyDoesNotExist() { + // arrange + // Supply๊ฐ€ ์—†๋Š” ์ƒํ’ˆ ์ƒ์„ฑ + Product productWithoutSupply = Product.create("์žฌ๊ณ ์—†๋Š”์ƒํ’ˆ", brandId, new Price(10000)); + Product savedProduct = productJpaRepository.save(productWithoutSupply); + ProductMetrics metrics = ProductMetrics.create(savedProduct.getId(), 0); + productMetricsJpaRepository.save(metrics); + // Supply๋Š” ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ + + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(savedProduct.getId(), 1) + ) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(exception.getMessage()).contains("์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + } + + @DisplayName("ํฌ์ธํŠธ ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_pointInsufficient() { + // arrange + // ํฌ์ธํŠธ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉ + OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10) + ) + ); + orderFacade.createOrder(userId, firstOrder); + + // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 1) // 20000์› ํ•„์š” (๋ถ€์กฑ) + ) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + // Note: ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ๊ฐ€ ๋จผ์ € ๋ฐœ์ƒํ•  ์ˆ˜๋„ ์žˆ์œผ๋ฏ€๋กœ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋ฉ”์‹œ์ง€๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ + // ๋˜๋Š” ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ (99999๋Š” ์žฌ๊ณ  ๋ถ€์กฑ) + } + + @DisplayName("ํฌ์ธํŠธ๊ฐ€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค. (Edge Case)") + @Test + void should_createOrder_when_pointExactlyMatches() { + // arrange + // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ + OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ + ) + ); + orderFacade.createOrder(userId, firstOrder); + // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› + + // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์ฃผ๋ฌธ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› + ) + ); + + // act + OrderInfo orderInfo = orderFacade.createOrder(userId, request); + + // assert + assertThat(orderInfo).isNotNull(); + assertThat(orderInfo.totalPrice()).isEqualTo(10000); + } + + @DisplayName("์ค‘๋ณต ์ƒํ’ˆ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ, IllegalStateException์ด ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_duplicateProducts() { + // arrange + // ๊ฐ™์€ ์ƒํ’ˆ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ํฌํ•จ + // Note: Collectors.toMap()์€ ์ค‘๋ณต ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด IllegalStateException์„ ๋ฐœ์ƒ์‹œํ‚ด + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 3) // ์ค‘๋ณต + ) + ); + + // act & assert + // Note: Collectors.toMap()์—์„œ ์ค‘๋ณต ํ‚ค๋กœ ์ธํ•ด IllegalStateException ๋ฐœ์ƒ + assertThrows(IllegalStateException.class, () -> orderFacade.createOrder(userId, request)); + } + + // Note: ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ ๊ฒ€์ฆ์€ E2E ํ…Œ์ŠคํŠธ์—์„œ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ๋” ์ ์ ˆํ•จ + // ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋Š” @Transactional๋กœ ์ธํ•ด ๋กค๋ฐฑ์ด ์ œ๋Œ€๋กœ ๊ฒ€์ฆ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž๋กœ ์ฃผ๋ฌธ ์‹œ๋„ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_userDoesNotExist() { + // arrange + String nonExistentUserId = "nonexist"; + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(nonExistentUserId, request)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(exception.getMessage()).contains("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + } + + // Note: Point ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž ํ…Œ์ŠคํŠธ๋Š” E2E ํ…Œ์ŠคํŠธ์—์„œ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ๋” ์ ์ ˆํ•จ + // ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋Š” @Transactional๋กœ ์ธํ•ด ๋กค๋ฐฑ์ด ์ œ๋Œ€๋กœ ๊ฒ€์ฆ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ + // E2E ํ…Œ์ŠคํŠธ์—์„œ ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ์„ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ์Œ + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..3b6dd2093 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,127 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("๋ธŒ๋žœ๋“œ(Brand) Entity ํ…Œ์ŠคํŠธ") +public class BrandTest { + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ •์ƒ์ ์ธ ์ด๋ฆ„์œผ๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createBrand_when_validName() { + // arrange + String brandName = "Nike"; + + // act + Brand brand = Brand.create(brandName); + + // assert + assertThat(brand.getName()).isEqualTo("Nike"); + } + + @DisplayName("๋นˆ ๋ฌธ์ž์—ด๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_emptyName() { + // arrange + String brandName = ""; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Brand.create(brandName)); + assertThat(exception.getMessage()).isEqualTo("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_nullName() { + // arrange + String brandName = null; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Brand.create(brandName)); + assertThat(exception.getMessage()).isEqualTo("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๊ณต๋ฐฑ๋งŒ ์žˆ๋Š” ๋ฌธ์ž์—ด๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_blankName() { + // arrange + String brandName = " "; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Brand.create(brandName)); + assertThat(exception.getMessage()).isEqualTo("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("๊ธด ์ด๋ฆ„์œผ๋กœ๋„ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_createBrand_when_longName() { + // arrange + String brandName = "A".repeat(1000); + + // act + Brand brand = Brand.create(brandName); + + // assert + assertThat(brand.getName()).isEqualTo("A".repeat(1000)); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์กฐํšŒ๋ฅผ ํ•  ๋•Œ, ") + @Nested + class Retrieve { + @DisplayName("์ƒ์„ฑํ•œ ๋ธŒ๋žœ๋“œ์˜ ์ด๋ฆ„์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_retrieveName_when_brandCreated() { + // arrange + Brand brand = Brand.create("Adidas"); + + // act + String name = brand.getName(); + + // assert + assertThat(name).isEqualTo("Adidas"); + } + } + + @DisplayName("๋ธŒ๋žœ๋“œ ๋™๋“ฑ์„ฑ์„ ํ™•์ธํ•  ๋•Œ, ") + @Nested + class Equality { + @DisplayName("๊ฐ™์€ ์ด๋ฆ„์„ ๊ฐ€์ง„ ๋ธŒ๋žœ๋“œ๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Edge Case)") + @Test + void should_beDifferentInstances_when_sameName() { + // arrange + String brandName = "Puma"; + Brand brand1 = Brand.create(brandName); + Brand brand2 = Brand.create(brandName); + + // act & assert + assertThat(brand1).isNotSameAs(brand2); + assertThat(brand1).isNotEqualTo(brand2); + } + + @DisplayName("๋‹ค๋ฅธ ์ด๋ฆ„์„ ๊ฐ€์ง„ ๋ธŒ๋žœ๋“œ๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Happy Path)") + @Test + void should_beDifferentInstances_when_differentNames() { + // arrange + Brand brand1 = Brand.create("Nike"); + Brand brand2 = Brand.create("Adidas"); + + // act & assert + assertThat(brand1).isNotSameAs(brand2); + assertThat(brand1).isNotEqualTo(brand2); + assertThat(brand1.getName()).isNotEqualTo(brand2.getName()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java new file mode 100644 index 000000000..f4427c64b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java @@ -0,0 +1,62 @@ +package com.loopers.domain.common.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("๊ฐ€๊ฒฉ(Price) Value Object ํ…Œ์ŠคํŠธ") +public class PriceTest { + + @DisplayName("๊ฐ€๊ฒฉ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ •์ƒ์ ์ธ ๊ฐ€๊ฒฉ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createPrice_when_validAmount() { + // arrange + int amount = 10000; + + // act + Price price = new Price(amount); + + // assert + assertThat(price.amount()).isEqualTo(10000); + } + + @DisplayName("๊ฐ€๊ฒฉ์ด 0์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_createPrice_when_amountIsZero() { + // arrange + int amount = 0; + + // act + Price price = new Price(amount); + + // assert + assertThat(price.amount()).isEqualTo(0); + } + + @DisplayName("์Œ์ˆ˜ ๊ฐ€๊ฒฉ์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @ParameterizedTest + @ValueSource(ints = {-1, -10, -100, -1000, -10000}) + void should_throwException_when_amountIsNegative(int invalidAmount) { + // arrange: invalidAmount parameter + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + new Price(invalidAmount); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java new file mode 100644 index 000000000..d339870ce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java @@ -0,0 +1,232 @@ +package com.loopers.domain.like.product; + +import com.loopers.domain.product.ProductService; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +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.context.bean.override.mockito.MockitoSpyBean; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest +@Transactional +@DisplayName("์ƒํ’ˆ ์ข‹์•„์š” ์„œ๋น„์Šค(LikeProductService) ํ…Œ์ŠคํŠธ") +public class LikeProductServiceIntegrationTest { + + @MockitoSpyBean + private LikeProductRepository spyLikeProductRepository; + + @MockitoSpyBean + private ProductService spyProductService; + + @Autowired + private LikeProductService likeProductService; + + @DisplayName("์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•  ๋•Œ, ") + @Nested + class LikeProductTest { + @DisplayName("์ฒ˜์Œ ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•˜๋ฉด ์ƒˆ๋กœ์šด ์ข‹์•„์š”๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค. (Happy Path)") + @Test + void should_createLikeProduct_when_firstLike() { + // arrange + Long userId = 1L; + Long productId = 100L; + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.empty()); + + // act + likeProductService.likeProduct(userId, productId); + + // assert + verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); + ArgumentCaptor captor = ArgumentCaptor.forClass(LikeProduct.class); + verify(spyLikeProductRepository, times(1)).save(captor.capture()); + LikeProduct savedLike = captor.getValue(); + assertThat(savedLike.getUserId()).isEqualTo(1L); + assertThat(savedLike.getProductId()).isEqualTo(100L); + } + + @DisplayName("์ด๋ฏธ ์‚ญ์ œ๋œ ์ข‹์•„์š”๋ฅผ ๋‹ค์‹œ ๋“ฑ๋กํ•˜๋ฉด ๋ณต์›๋œ๋‹ค. (Idempotency)") + @Test + void should_restoreLikeProduct_when_alreadyDeleted() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct deletedLike = LikeProduct.create(userId, productId); + deletedLike.delete(); // ์‚ญ์ œ ์ƒํƒœ๋กœ ๋งŒ๋“ค๊ธฐ + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.of(deletedLike)); + + // act + likeProductService.likeProduct(userId, productId); + + // assert + verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); + verify(spyLikeProductRepository, never()).save(any()); + // restore๊ฐ€ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ (deletedAt์ด null์ด ๋˜์–ด์•ผ ํ•จ) + assertThat(deletedLike.getDeletedAt()).isNull(); + } + + @DisplayName("๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐ™์€ ์ƒํ’ˆ์— ์—ฌ๋Ÿฌ ๋ฒˆ ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•ด๋„ ํ•œ ๋ฒˆ๋งŒ ์ €์žฅ๋œ๋‹ค. (Idempotency)") + @Test + void should_notCreateDuplicate_when_likeMultipleTimes() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct existingLike = LikeProduct.create(userId, productId); + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.of(existingLike)); + + // act + likeProductService.likeProduct(userId, productId); + likeProductService.likeProduct(userId, productId); + + // assert + verify(spyLikeProductRepository, times(2)).findByUserIdAndProductId(1L, 100L); + verify(spyLikeProductRepository, never()).save(any()); + } + } + + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•  ๋•Œ, ") + @Nested + class UnlikeProduct { + @DisplayName("์กด์žฌํ•˜๋Š” ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•˜๋ฉด ์‚ญ์ œ๋œ๋‹ค. (Happy Path)") + @Test + void should_deleteLikeProduct_when_likeExists() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct likeProduct = LikeProduct.create(userId, productId); + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.of(likeProduct)); + + // act + likeProductService.unlikeProduct(userId, productId); + + // assert + verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); + assertThat(likeProduct.getDeletedAt()).isNotNull(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. (Edge Case)") + @Test + void should_notThrowException_when_likeNotFound() { + // arrange + Long userId = 1L; + Long productId = 100L; + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.empty()); + + // act & assert + likeProductService.unlikeProduct(userId, productId); + verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); + // ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์•„์•ผ ํ•จ + } + + @DisplayName("์ด๋ฏธ ์‚ญ์ œ๋œ ์ข‹์•„์š”๋ฅผ ๋‹ค์‹œ ์ทจ์†Œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค. (Idempotency)") + @Test + void should_beIdempotent_when_unlikeDeletedLike() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct deletedLike = LikeProduct.create(userId, productId); + deletedLike.delete(); + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.of(deletedLike)); + + // act + likeProductService.unlikeProduct(userId, productId); + + // assert + verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); + // delete๋Š” ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•˜๋ฏ€๋กœ deletedAt์ด ๊ทธ๋Œ€๋กœ ์œ ์ง€๋˜์–ด์•ผ ํ•จ + assertThat(deletedLike.getDeletedAt()).isNotNull(); + } + } + + @DisplayName("์ข‹์•„์š” ํ† ๊ธ€์„ ํ•  ๋•Œ, ") + @Nested + class ToggleLike { + @DisplayName("์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด ๋“ฑ๋กํ•˜๊ณ , ์žˆ์œผ๋ฉด ์ทจ์†Œํ•œ๋‹ค. (Toggle)") + @Test + void should_toggleLike_when_likeAndUnlike() { + // arrange + Long userId = 1L; + Long productId = 100L; + when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(LikeProduct.create(userId, productId))); + + // act - ์ฒซ ๋ฒˆ์งธ ํ˜ธ์ถœ: ์ข‹์•„์š” ๋“ฑ๋ก + likeProductService.likeProduct(userId, productId); + // ๋‘ ๋ฒˆ์งธ ํ˜ธ์ถœ: ์ข‹์•„์š” ์ทจ์†Œ + likeProductService.unlikeProduct(userId, productId); + + // assert + verify(spyLikeProductRepository, times(2)).findByUserIdAndProductId(1L, 100L); + verify(spyLikeProductRepository, times(1)).save(any(LikeProduct.class)); + } + } + + @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetLikedProducts { + @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ด ์žˆ์œผ๋ฉด ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnLikedProducts_when_likesExist() { + // arrange + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 20); + List likedProducts = List.of( + LikeProduct.create(userId, 100L), + LikeProduct.create(userId, 200L) + ); + Page productPage = new PageImpl<>(likedProducts, pageable, 2); + when(spyLikeProductRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, pageable)) + .thenReturn(productPage); + + // act + Page result = likeProductService.getLikedProducts(userId, pageable); + + // assert + verify(spyLikeProductRepository).getLikeProductsByUserIdAndDeletedAtIsNull(1L, Pageable.ofSize(20)); + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ด ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") + @Test + void should_returnEmptyList_when_noLikes() { + // arrange + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 20); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + when(spyLikeProductRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, pageable)) + .thenReturn(emptyPage); + + // act + Page result = likeProductService.getLikedProducts(userId, pageable); + + // assert + verify(spyLikeProductRepository).getLikeProductsByUserIdAndDeletedAtIsNull(1L, Pageable.ofSize(20)); + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java new file mode 100644 index 000000000..4b072ff62 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java @@ -0,0 +1,202 @@ +package com.loopers.domain.like.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("์ƒํ’ˆ ์ข‹์•„์š”(LikeProduct) Entity ํ…Œ์ŠคํŠธ") +public class LikeProductTest { + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ •์ƒ์ ์ธ userId์™€ productId๋กœ ์ข‹์•„์š”๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createLikeProduct_when_validUserIdAndProductId() { + // arrange + Long userId = 1L; + Long productId = 100L; + + // act + LikeProduct likeProduct = LikeProduct.create(userId, productId); + + // assert + assertThat(likeProduct.getUserId()).isEqualTo(1L); + assertThat(likeProduct.getProductId()).isEqualTo(100L); + } + + @DisplayName("userId๊ฐ€ 0์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_userIdIsZero() { + // arrange + Long userId = 0L; + Long productId = 100L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId๊ฐ€ 0์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdIsZero() { + // arrange + Long userId = 1L; + Long productId = 0L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("userId์™€ productId๊ฐ€ ๋ชจ๋‘ 0์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_bothIdsAreZero() { + // arrange + Long userId = 0L; + Long productId = 0L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์Œ์ˆ˜ userId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_userIdIsNegative() { + // arrange + Long userId = -1L; + Long productId = 100L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์Œ์ˆ˜ productId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdIsNegative() { + // arrange + Long userId = 1L; + Long productId = -1L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null userId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_userIdIsNull() { + // arrange + Long userId = null; + Long productId = 100L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null productId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdIsNull() { + // arrange + Long userId = 1L; + Long productId = null; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์ข‹์•„์š” ์กฐํšŒ๋ฅผ ํ•  ๋•Œ, ") + @Nested + class Retrieve { + @DisplayName("์ƒ์„ฑํ•œ ์ข‹์•„์š”์˜ userId๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_retrieveUserId_when_likeProductCreated() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct likeProduct = LikeProduct.create(userId, productId); + + // act + Long retrievedUserId = likeProduct.getUserId(); + + // assert + assertThat(retrievedUserId).isEqualTo(1L); + } + + @DisplayName("์ƒ์„ฑํ•œ ์ข‹์•„์š”์˜ productId๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_retrieveProductId_when_likeProductCreated() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct likeProduct = LikeProduct.create(userId, productId); + + // act + Long retrievedProductId = likeProduct.getProductId(); + + // assert + assertThat(retrievedProductId).isEqualTo(100L); + } + } + + @DisplayName("์ข‹์•„์š” ๋™๋“ฑ์„ฑ์„ ํ™•์ธํ•  ๋•Œ, ") + @Nested + class Equality { + @DisplayName("๊ฐ™์€ userId์™€ productId๋ฅผ ๊ฐ€์ง„ ์ข‹์•„์š”๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Edge Case)") + @Test + void should_beDifferentInstances_when_sameUserIdAndProductId() { + // arrange + Long userId = 1L; + Long productId = 100L; + LikeProduct likeProduct1 = LikeProduct.create(userId, productId); + LikeProduct likeProduct2 = LikeProduct.create(userId, productId); + + // act & assert + assertThat(likeProduct1).isNotSameAs(likeProduct2); + assertThat(likeProduct1).isNotEqualTo(likeProduct2); + } + + @DisplayName("๋‹ค๋ฅธ userId๋ฅผ ๊ฐ€์ง„ ์ข‹์•„์š”๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Happy Path)") + @Test + void should_beDifferentInstances_when_differentUserId() { + // arrange + LikeProduct likeProduct1 = LikeProduct.create(1L, 100L); + LikeProduct likeProduct2 = LikeProduct.create(2L, 100L); + + // act & assert + assertThat(likeProduct1).isNotSameAs(likeProduct2); + assertThat(likeProduct1).isNotEqualTo(likeProduct2); + assertThat(likeProduct1.getUserId()).isNotEqualTo(likeProduct2.getUserId()); + } + + @DisplayName("๋‹ค๋ฅธ productId๋ฅผ ๊ฐ€์ง„ ์ข‹์•„์š”๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Happy Path)") + @Test + void should_beDifferentInstances_when_differentProductId() { + // arrange + LikeProduct likeProduct1 = LikeProduct.create(1L, 100L); + LikeProduct likeProduct2 = LikeProduct.create(1L, 200L); + + // act & assert + assertThat(likeProduct1).isNotSameAs(likeProduct2); + assertThat(likeProduct1).isNotEqualTo(likeProduct2); + assertThat(likeProduct1.getProductId()).isNotEqualTo(likeProduct2.getProductId()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..35a5056a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,276 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.vo.Price; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ(OrderItem) Value Object ํ…Œ์ŠคํŠธ") +public class OrderItemTest { + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ •์ƒ์ ์ธ ๊ฐ’์œผ๋กœ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createOrderItem_when_validValues() { + // arrange + Long productId = 1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 2; + Price price = new Price(10000); + + // act + OrderItem orderItem = OrderItem.create(productId, productName, quantity, price); + + // assert + assertThat(orderItem.getProductId()).isEqualTo(1L); + assertThat(orderItem.getProductName()).isEqualTo("์ƒํ’ˆ๋ช…"); + assertThat(orderItem.getQuantity()).isEqualTo(2); + assertThat(orderItem.getPrice().amount()).isEqualTo(10000); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_quantityIsZero() { + // arrange + Long productId = 1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 0; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_quantityIsNegative() { + // arrange + Long productId = 1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = -1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("๊ฐ€๊ฒฉ์ด 0์ธ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_createOrderItem_when_priceIsZero() { + // arrange + Long productId = 1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 1; + Price price = new Price(0); + + // act + OrderItem orderItem = OrderItem.create(productId, productName, quantity, price); + + // assert + assertThat(orderItem.getPrice().amount()).isEqualTo(0); + } + + @DisplayName("productName์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productNameIsNull() { + // arrange + Long productId = 1L; + String productName = null; + Integer quantity = 1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("productName์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productNameIsEmpty() { + // arrange + Long productId = 1L; + String productName = ""; + Integer quantity = 1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("productName์ด ๊ณต๋ฐฑ๋งŒ ์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productNameIsBlank() { + // arrange + Long productId = 1L; + String productName = " "; + Integer quantity = 1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @DisplayName("productId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdIsNull() { + // arrange + Long productId = null; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("productId๊ฐ€ 0์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdIsZero() { + // arrange + Long productId = 0L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("productId๊ฐ€ ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productIdIsNegative() { + // arrange + Long productId = -1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 1; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("quantity๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_quantityIsNull() { + // arrange + Long productId = 1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = null; + Price price = new Price(10000); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("price๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_priceIsNull() { + // arrange + Long productId = 1L; + String productName = "์ƒํ’ˆ๋ช…"; + Integer quantity = 1; + Price price = null; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + OrderItem.create(productId, productName, quantity, price); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์˜ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ๋•Œ, ") + @Nested + class GetTotalPrice { + @DisplayName("์ •์ƒ์ ์ธ ์ˆ˜๋Ÿ‰๊ณผ ๊ฐ€๊ฒฉ์œผ๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_calculateTotalPrice_when_validQuantityAndPrice() { + // arrange + OrderItem orderItem = OrderItem.create(1L, "์ƒํ’ˆ๋ช…", 3, new Price(10000)); + + // act + Integer totalPrice = orderItem.getTotalPrice(); + + // assert + assertThat(totalPrice).isEqualTo(30000); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ด๋ฉด ๊ฐ€๊ฒฉ๊ณผ ๋™์ผํ•˜๋‹ค. (Edge Case)") + @Test + void should_returnPrice_when_quantityIsOne() { + // arrange + OrderItem orderItem = OrderItem.create(1L, "์ƒํ’ˆ๋ช…", 1, new Price(10000)); + + // act + Integer totalPrice = orderItem.getTotalPrice(); + + // assert + assertThat(totalPrice).isEqualTo(10000); + } + + @DisplayName("๊ฐ€๊ฒฉ์ด 0์ด๋ฉด ์ด ๊ฐ€๊ฒฉ์ด 0์ด๋‹ค. (Edge Case)") + @Test + void should_returnZero_when_priceIsZero() { + // arrange + OrderItem orderItem = OrderItem.create(1L, "์ƒํ’ˆ๋ช…", 3, new Price(0)); + + // act + Integer totalPrice = orderItem.getTotalPrice(); + + // assert + assertThat(totalPrice).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..1e524bdf0 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,145 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.vo.Price; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest +@Transactional +@DisplayName("์ฃผ๋ฌธ ์„œ๋น„์Šค(OrderService) ํ…Œ์ŠคํŠธ") +public class OrderServiceIntegrationTest { + + @MockitoSpyBean + private OrderRepository spyOrderRepository; + + @Autowired + private OrderService orderService; + + @DisplayName("์ฃผ๋ฌธ์„ ์ €์žฅํ•  ๋•Œ, ") + @Nested + class SaveOrder { + @DisplayName("์ •์ƒ์ ์ธ ์ฃผ๋ฌธ์„ ์ €์žฅํ•˜๋ฉด ์ฃผ๋ฌธ์ด ์ €์žฅ๋œ๋‹ค. (Happy Path)") + @Test + void should_saveOrder_when_validOrder() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), + OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)) + ); + Order order = Order.create(userId, orderItems); +// when(spyOrderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.save(order); + + // assert + verify(spyOrderRepository).save(any(Order.class)); + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getOrderItems()).hasSize(2); + } + + @DisplayName("๋‹จ์ผ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ๊ฐ€์ง„ ์ฃผ๋ฌธ์„ ์ €์žฅํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_saveOrder_when_singleOrderItem() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) + ); + Order order = Order.create(userId, orderItems); +// when(spyOrderRepository.save(any(Order.class))).thenReturn(order); + + // act + Order result = orderService.save(order); + + // assert + verify(spyOrderRepository).save(any(Order.class)); + assertThat(result).isNotNull(); + assertThat(result.getOrderItems()).hasSize(1); + } + } + + @DisplayName("์ฃผ๋ฌธ ID์™€ ์‚ฌ์šฉ์ž ID๋กœ ์ฃผ๋ฌธ์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetOrderByIdAndUserId { + @DisplayName("์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ ID์™€ ์‚ฌ์šฉ์ž ID๋กœ ์กฐํšŒํ•˜๋ฉด ์ฃผ๋ฌธ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnOrder_when_orderExists() { + // arrange + Long orderId = 1L; + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)) + ); + Order order = Order.create(userId, orderItems); + when(spyOrderRepository.findByIdAndUserId(orderId, userId)).thenReturn(Optional.of(order)); + + // act + Order result = orderService.getOrderByIdAndUserId(orderId, userId); + + // assert + verify(spyOrderRepository).findByIdAndUserId(1L, 1L); + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getOrderItems()).hasSize(1); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_orderNotFound() { + // arrange + Long orderId = 999L; + Long userId = 1L; + when(spyOrderRepository.findByIdAndUserId(orderId, userId)).thenReturn(Optional.empty()); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.getOrderByIdAndUserId(orderId, userId); + }); + + // assert + verify(spyOrderRepository).findByIdAndUserId(999L, 1L); + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_orderBelongsToDifferentUser() { + // arrange + Long orderId = 1L; + Long userId = 1L; + Long differentUserId = 2L; + when(spyOrderRepository.findByIdAndUserId(orderId, differentUserId)).thenReturn(Optional.empty()); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.getOrderByIdAndUserId(orderId, differentUserId); + }); + + // assert + verify(spyOrderRepository).findByIdAndUserId(1L, 2L); + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..b4521bb1b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,176 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.vo.Price; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("์ฃผ๋ฌธ(Order) Entity ํ…Œ์ŠคํŠธ") +public class OrderTest { + + @DisplayName("์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ •์ƒ์ ์ธ userId์™€ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createOrder_when_validUserIdAndOrderItems() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), + OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)) + ); + + // act + Order order = Order.create(userId, orderItems); + + // assert + assertThat(order.getUserId()).isEqualTo(1L); + assertThat(order.getOrderItems()).hasSize(2); + assertThat(order.getOrderItems().get(0).getProductId()).isEqualTo(1L); + assertThat(order.getOrderItems().get(1).getProductId()).isEqualTo(2L); + } + + @DisplayName("๋‹จ์ผ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_createOrder_when_singleOrderItem() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) + ); + + // act + Order order = Order.create(userId, orderItems); + + // assert + assertThat(order.getUserId()).isEqualTo(1L); + assertThat(order.getOrderItems()).hasSize(1); + } + + @DisplayName("๋นˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_emptyOrderItems() { + // arrange + Long userId = 1L; + List orderItems = new ArrayList<>(); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_nullOrderItems() { + // arrange + Long userId = 1L; + List orderItems = null; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null userId๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_nullUserId() { + // arrange + Long userId = null; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); + assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("0 ์ดํ•˜์˜ userId๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_invalidUserId() { + // arrange + Long userId = 0L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) + ); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); + assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_createOrder_when_multipleOrderItems() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), + OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)), + OrderItem.create(3L, "์ƒํ’ˆ3", 3, new Price(15000)) + ); + + // act + Order order = Order.create(userId, orderItems); + + // assert + assertThat(order.getUserId()).isEqualTo(1L); + assertThat(order.getOrderItems()).hasSize(3); + } + + } + + @DisplayName("์ฃผ๋ฌธ ์กฐํšŒ๋ฅผ ํ•  ๋•Œ, ") + @Nested + class Retrieve { + @DisplayName("์ƒ์„ฑํ•œ ์ฃผ๋ฌธ์˜ userId๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_retrieveUserId_when_orderCreated() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) + ); + Order order = Order.create(userId, orderItems); + + // act + Long retrievedUserId = order.getUserId(); + + // assert + assertThat(retrievedUserId).isEqualTo(1L); + } + + @DisplayName("์ƒ์„ฑํ•œ ์ฃผ๋ฌธ์˜ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_retrieveOrderItems_when_orderCreated() { + // arrange + Long userId = 1L; + List orderItems = List.of( + OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), + OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)) + ); + Order order = Order.create(userId, orderItems); + + // act + List retrievedOrderItems = order.getOrderItems(); + + // assert + assertThat(retrievedOrderItems).hasSize(2); + assertThat(retrievedOrderItems.get(0).getProductId()).isEqualTo(1L); + assertThat(retrievedOrderItems.get(1).getProductId()).isEqualTo(2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..f7ccc360f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,362 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.vo.Price; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest +@Transactional +@DisplayName("์ƒํ’ˆ ์„œ๋น„์Šค(ProductService) ํ…Œ์ŠคํŠธ") +public class ProductServiceIntegrationTest { + + @MockitoSpyBean + private ProductRepository spyProductRepository; + + @Autowired + private ProductService productService; + + @Autowired + private com.loopers.infrastructure.brand.BrandJpaRepository brandJpaRepository; + + @Autowired + private com.loopers.infrastructure.product.ProductJpaRepository productJpaRepository; + + @Autowired + private com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long brandId; + private Long productId1; + private Long productId2; + private Long productId3; + + @BeforeEach + void setup() { + // Brand ๋“ฑ๋ก + Brand brand = Brand.create("Nike"); + Brand savedBrand = brandJpaRepository.save(brand); + brandId = savedBrand.getId(); + + // Product ๋“ฑ๋ก + Product product1 = Product.create("์ƒํ’ˆ1", brandId, new Price(10000)); + Product savedProduct1 = productJpaRepository.save(product1); + productId1 = savedProduct1.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics1 = ProductMetrics.create(productId1, 4); + productMetricsJpaRepository.save(metrics1); + + Product product2 = Product.create("์ƒํ’ˆ2", brandId, new Price(20000)); + Product savedProduct2 = productJpaRepository.save(product2); + productId2 = savedProduct2.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); + productMetricsJpaRepository.save(metrics2); + + Product product3 = Product.create("์ƒํ’ˆ3", brandId, new Price(15000)); + Product savedProduct3 = productJpaRepository.save(product3); + productId3 = savedProduct3.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics3 = ProductMetrics.create(productId3, 3); + productMetricsJpaRepository.save(metrics3); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetProductById { + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnProduct_when_productExists() { + // arrange + Long productId = 1L; + Product product = createProduct(productId, "์ƒํ’ˆ๋ช…", 1L, 10000); + when(spyProductRepository.findById(productId)).thenReturn(Optional.of(product)); + + // act + Product result = productService.getProductById(productId); + + // assert + verify(spyProductRepository).findById(1L); + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(1L); + assertThat(result.getName()).isEqualTo("์ƒํ’ˆ๋ช…"); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_productNotFound() { + // arrange + Long productId = 999L; + when(spyProductRepository.findById(productId)).thenReturn(Optional.empty()); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + productService.getProductById(productId); + }); + + // assert + verify(spyProductRepository).findById(999L); + assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ ๋งต์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetProductMapByIds { + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋“ค๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ๋งต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnProductMap_when_productsExist() { + // arrange + List productIds = List.of(1L, 2L, 3L); + List products = List.of( + createProduct(1L, "์ƒํ’ˆ1", 1L, 10000), + createProduct(2L, "์ƒํ’ˆ2", 1L, 20000), + createProduct(3L, "์ƒํ’ˆ3", 2L, 15000) + ); + when(spyProductRepository.findAllByIdIn(productIds)).thenReturn(products); + + // act + Map result = productService.getProductMapByIds(productIds); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).hasSize(3); + assertThat(result.get(1L).getName()).isEqualTo("์ƒํ’ˆ1"); + assertThat(result.get(2L).getName()).isEqualTo("์ƒํ’ˆ2"); + assertThat(result.get(3L).getName()).isEqualTo("์ƒํ’ˆ3"); + } + + @DisplayName("๋นˆ ID ๋ฆฌ์ŠคํŠธ๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ ๋งต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") + @Test + void should_returnEmptyMap_when_emptyIdList() { + // arrange + List productIds = Collections.emptyList(); + when(spyProductRepository.findAllByIdIn(productIds)).thenReturn(Collections.emptyList()); + + // act + Map result = productService.getProductMapByIds(productIds); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).isEmpty(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋“ค๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ ๋งต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") + @Test + void should_returnEmptyMap_when_productsNotFound() { + // arrange + List productIds = List.of(999L, 1000L); + when(spyProductRepository.findAllByIdIn(productIds)).thenReturn(Collections.emptyList()); + + // act + Map result = productService.getProductMapByIds(productIds); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).isEmpty(); + } + } + + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ, ") + @Nested + class GetProducts { + @DisplayName("๊ธฐ๋ณธ ํŽ˜์ด์ง€๋„ค์ด์…˜์œผ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnProductPage_when_defaultPageable() { + // arrange + Pageable pageable = PageRequest.of(0, 20); + + // act + Page result = productService.getProducts(pageable); + + // assert + verify(spyProductRepository).findAll(any(Pageable.class)); + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @DisplayName("์ตœ์‹ ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnProductPage_when_sortedByLatest() { + // arrange + Sort sort = Sort.by("latest"); + Pageable pageable = PageRequest.of(0, 20, sort); + + // act + Page result = productService.getProducts(pageable); + + // assert + verify(spyProductRepository).findAll(any(Pageable.class)); + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + // ์ตœ์‹ ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ createdAt ๊ธฐ์ค€ ๋‚ด๋ฆผ์ฐจ์ˆœ + assertThat(result.getContent().get(0).getCreatedAt()).isAfterOrEqualTo(result.getContent().get(1).getCreatedAt()); + assertThat(result.getContent().get(1).getCreatedAt()).isAfterOrEqualTo(result.getContent().get(2).getCreatedAt()); + } + + @DisplayName("๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnProductPage_when_sortedByPriceAsc() { + // arrange + Sort sort = Sort.by("price_asc"); + Pageable pageable = PageRequest.of(0, 20, sort); + + // act + Page result = productService.getProducts(pageable); + + // assert + verify(spyProductRepository).findAll(any(Pageable.class)); + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + // ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ + assertThat(result.getContent().get(0).getPrice().amount()).isLessThanOrEqualTo(result.getContent().get(1).getPrice().amount()); + assertThat(result.getContent().get(1).getPrice().amount()).isLessThanOrEqualTo(result.getContent().get(2).getPrice().amount()); + } + + @DisplayName("์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnProductPage_when_sortedByLikesDesc() { + // arrange + Sort sort = Sort.by("likes_desc"); + Pageable pageable = PageRequest.of(0, 20, sort); + + // act + Page result = productService.getProducts(pageable); + + // assert + verify(spyProductRepository).findAll(any(Pageable.class)); + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(3); + // ์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ + assertThat(result.getContent().get(0).getId()).isGreaterThanOrEqualTo(result.getContent().get(1).getId()); + assertThat(result.getContent().get(1).getId()).isGreaterThanOrEqualTo(result.getContent().get(2).getId()); + } + } + + @DisplayName("์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ๋•Œ, ") + @Nested + class CalculateTotalAmount { + @DisplayName("์ •์ƒ์ ์ธ ์ƒํ’ˆ๊ณผ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_calculateTotalAmount_when_validProductsAndQuantities() { + // arrange + Map items = Map.of( + 1L, 2, + 2L, 3 + ); + List products = List.of( + createProduct(1L, "์ƒํ’ˆ1", 1L, 10000), + createProduct(2L, "์ƒํ’ˆ2", 1L, 20000) + ); + when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); + + // act + Integer result = productService.calculateTotalAmount(items); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).isEqualTo(80000); + } + + @DisplayName("๋‹จ์ผ ์ƒํ’ˆ์œผ๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_calculateTotalAmount_when_singleProduct() { + // arrange + Map items = Map.of(1L, 5); + List products = List.of(createProduct(1L, "์ƒํ’ˆ1", 1L, 10000)); + when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); + + // act + Integer result = productService.calculateTotalAmount(items); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).isEqualTo(50000); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ธ ์ƒํ’ˆ๋“ค๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_calculateTotalAmount_when_quantityIsOne() { + // arrange + Map items = Map.of( + 1L, 1, + 2L, 1 + ); + List products = List.of( + createProduct(1L, "์ƒํ’ˆ1", 1L, 10000), + createProduct(2L, "์ƒํ’ˆ2", 1L, 20000) + ); + when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); + + // act + Integer result = productService.calculateTotalAmount(items); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).isEqualTo(30000); + } + + @DisplayName("๊ฐ€๊ฒฉ์ด 0์ธ ์ƒํ’ˆ์ด ํฌํ•จ๋˜์–ด๋„ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_calculateTotalAmount_when_priceIsZero() { + // arrange + Map items = Map.of( + 1L, 2, + 2L, 1 + ); + List products = List.of( + createProduct(1L, "์ƒํ’ˆ1", 1L, 0), + createProduct(2L, "์ƒํ’ˆ2", 1L, 20000) + ); + when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); + + // act + Integer result = productService.calculateTotalAmount(items); + + // assert + verify(spyProductRepository).findAllByIdIn(any(Collection.class)); + assertThat(result).isEqualTo(20000); + } + } + + private Product createProduct(Long id, String name, Long brandId, int priceAmount) { + Product product = Product.create(name, brandId, new Price(priceAmount)); + // ํ…Œ์ŠคํŠธ์šฉ์œผ๋กœ id ์„ค์ • (๋ฆฌํ”Œ๋ ‰์…˜ ์‚ฌ์šฉ) + try { + java.lang.reflect.Field idField = Product.class.getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(product, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Product id", e); + } + return product; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java new file mode 100644 index 000000000..6a27fdd5a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java @@ -0,0 +1,130 @@ +package com.loopers.domain.supply; + +import com.loopers.domain.supply.vo.Stock; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("์žฌ๊ณ  ๊ณต๊ธ‰(Supply) Entity ํ…Œ์ŠคํŠธ") +public class SupplyTest { + + @DisplayName("์žฌ๊ณ  ์ฐจ๊ฐ์„ ํ•  ๋•Œ, ") + @Nested + class DecreaseStock { + @DisplayName("์ •์ƒ์ ์ธ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ๊ฐ์†Œํ•œ๋‹ค. (Happy Path)") + @Test + void should_decreaseStock_when_validQuantity() { + // arrange + Supply supply = createSupply(10); + int orderQuantity = 3; + + // act + supply.decreaseStock(orderQuantity); + + // assert + assertThat(supply.getStock().quantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ ์ˆ˜๋Ÿ‰๊ณผ ์ •ํ™•ํžˆ ๊ฐ™์œผ๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") + @Test + void should_setStockToZero_when_stockEqualsOrderQuantity() { + // arrange + Supply supply = createSupply(5); + int orderQuantity = 5; + + // act + supply.decreaseStock(orderQuantity); + + // assert + assertThat(supply.getStock().quantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ 1๊ฐœ์ผ ๋•Œ 1๊ฐœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") + @Test + void should_setStockToZero_when_stockIsOneAndDecreaseOne() { + // arrange + Supply supply = createSupply(1); + int orderQuantity = 1; + + // act + supply.decreaseStock(orderQuantity); + + // assert + assertThat(supply.getStock().quantity()).isEqualTo(0); + } + + @DisplayName("0 ์ดํ•˜์˜ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @ParameterizedTest + @ValueSource(ints = {0, -1, -10}) + void should_throwException_when_orderQuantityIsZeroOrNegative(int invalidQuantity) { + // arrange + Supply supply = createSupply(10); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + supply.decreaseStock(invalidQuantity); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_orderQuantityExceedsStock() { + // arrange + Supply supply = createSupply(5); + int orderQuantity = 10; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + supply.decreaseStock(orderQuantity); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ผ ๋•Œ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_stockIsZero() { + // arrange + Supply supply = createSupply(0); + int orderQuantity = 1; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + supply.decreaseStock(orderQuantity); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("์—ฌ๋Ÿฌ ๋ฒˆ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ๋ˆ„์  ๊ฐ์†Œํ•œ๋‹ค. (Edge Case)") + @Test + void should_accumulateDecrease_when_decreaseMultipleTimes() { + // arrange + Supply supply = createSupply(10); + + // act + supply.decreaseStock(2); + supply.decreaseStock(3); + supply.decreaseStock(1); + + // assert + assertThat(supply.getStock().quantity()).isEqualTo(4); + } + } + + private Supply createSupply(int stockQuantity) { + // ํ…Œ์ŠคํŠธ์šฉ์œผ๋กœ ๋”๋ฏธ productId ์‚ฌ์šฉ + return Supply.create(1L, new Stock(stockQuantity)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java new file mode 100644 index 000000000..81bf89a91 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java @@ -0,0 +1,183 @@ +package com.loopers.domain.supply.vo; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("์žฌ๊ณ (Stock) Value Object ํ…Œ์ŠคํŠธ") +public class StockTest { + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ •์ƒ์ ์ธ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") + @Test + void should_createStock_when_validQuantity() { + // arrange + int quantity = 10; + + // act + Stock stock = new Stock(quantity); + + // assert + assertThat(stock.quantity()).isEqualTo(10); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") + @Test + void should_createStock_when_quantityIsZero() { + // arrange + int quantity = 0; + + // act + Stock stock = new Stock(quantity); + + // assert + assertThat(stock.quantity()).isEqualTo(0); + } + + @DisplayName("์Œ์ˆ˜ ์žฌ๊ณ ๋กœ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_quantityIsNegative() { + // arrange + int quantity = -1; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> new Stock(quantity)); + assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์žฌ๊ณ  ์ฐจ๊ฐ์„ ํ•  ๋•Œ, ") + @Nested + class Decrease { + @DisplayName("์ •์ƒ์ ์ธ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ๊ฐ์†Œํ•œ๋‹ค. (Happy Path)") + @Test + void should_decreaseStock_when_validQuantity() { + // arrange + Stock stock = new Stock(10); + int orderQuantity = 3; + + // act + Stock result = stock.decrease(orderQuantity); + + // assert + assertThat(result.quantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ ์ˆ˜๋Ÿ‰๊ณผ ์ •ํ™•ํžˆ ๊ฐ™์œผ๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") + @Test + void should_setStockToZero_when_stockEqualsOrderQuantity() { + // arrange + Stock stock = new Stock(5); + int orderQuantity = 5; + + // act + Stock result = stock.decrease(orderQuantity); + + // assert + assertThat(result.quantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ 1๊ฐœ์ผ ๋•Œ 1๊ฐœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") + @Test + void should_setStockToZero_when_stockIsOneAndDecreaseOne() { + // arrange + Stock stock = new Stock(1); + int orderQuantity = 1; + + // act + Stock result = stock.decrease(orderQuantity); + + // assert + assertThat(result.quantity()).isEqualTo(0); + } + + @DisplayName("0 ์ดํ•˜์˜ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @ParameterizedTest + @ValueSource(ints = {0, -1, -10}) + void should_throwException_when_orderQuantityIsZeroOrNegative(int invalidQuantity) { + // arrange + Stock stock = new Stock(10); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.decrease(invalidQuantity); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_orderQuantityExceedsStock() { + // arrange + Stock stock = new Stock(5); + int orderQuantity = 10; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.decrease(orderQuantity); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ผ ๋•Œ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") + @Test + void should_throwException_when_stockIsZero() { + // arrange + Stock stock = new Stock(0); + int orderQuantity = 1; + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + stock.decrease(orderQuantity); + }); + + // assert + assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + } + + @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ ํ™•์ธ์„ ํ•  ๋•Œ, ") + @Nested + class IsOutOfStock { + @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") + @Test + void should_returnTrue_when_stockIsZero() { + // arrange + Stock stock = new Stock(0); + + // act + boolean result = stock.isOutOfStock(); + + // assert + assertThat(result).isTrue(); + } + + + @DisplayName("์žฌ๊ณ ๊ฐ€ 1 ์ด์ƒ์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") + @Test + void should_returnFalse_when_stockIsPositive() { + // arrange + Stock stock = new Stock(10); + + // act + boolean result = stock.isOutOfStock(); + + // assert + assertThat(result).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java new file mode 100644 index 000000000..de6c6d615 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java @@ -0,0 +1,485 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.vo.Price; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.product.Product; +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.SupplyService; +import com.loopers.domain.supply.vo.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.like.product.LikeProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class LikeProductV1ApiE2ETest { + + private final String ENDPOINT_USER = "/api/v1/users"; + private final String ENDPOINT_LIKE_PRODUCTS = "/api/v1/like/products"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductMetricsJpaRepository productMetricsJpaRepository; + private final SupplyService supplyService; + + @Autowired + public LikeProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductMetricsJpaRepository productMetricsJpaRepository, + SupplyService supplyService + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productMetricsJpaRepository = productMetricsJpaRepository; + this.supplyService = supplyService; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private final String validUserId = "user123"; + private final String validEmail = "xx@yy.zz"; + private final String validBirthday = "1993-03-13"; + private final String validGender = "male"; + + private Long brandId; + private Long productId1; + private Long productId2; + + @BeforeEach + @Transactional + void setupUserAndProducts() { + // User ๋“ฑ๋ก + UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( + validUserId, + validEmail, + validBirthday, + validGender + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // Brand ๋“ฑ๋ก + Brand brand = Brand.create("Nike"); + Brand savedBrand = brandJpaRepository.save(brand); + brandId = savedBrand.getId(); + + // Product ๋“ฑ๋ก + Product product1 = createProduct("์ƒํ’ˆ1", brandId, 10000); + Product savedProduct1 = productJpaRepository.save(product1); + productId1 = savedProduct1.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); + productMetricsJpaRepository.save(metrics1); + // Supply ๋“ฑ๋ก + Supply supply1 = Supply.create(productId1, new Stock(100)); + supplyService.saveSupply(supply1); + + Product product2 = createProduct("์ƒํ’ˆ2", brandId, 20000); + Product savedProduct2 = productJpaRepository.save(product2); + productId2 = savedProduct2.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); + productMetricsJpaRepository.save(metrics2); + // Supply ๋“ฑ๋ก + Supply supply2 = Supply.create(productId2, new Stock(200)); + supplyService.saveSupply(supply2); + } + + + private Product createProduct(String name, Long brandId, int priceAmount) { + return Product.create(name, brandId, new Price(priceAmount)); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", validUserId); + return headers; + } + + @DisplayName("POST /api/v1/like/products/{productId}") + @Nested + class PostLikeProduct { + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, `200 OK` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnOk_whenLikeProductSuccess() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("๊ฐ™์€ ์ƒํ’ˆ์— ์—ฌ๋Ÿฌ ๋ฒˆ ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") + @Test + void beIdempotent_whenLikeProductMultipleTimes() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response1 = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + ResponseEntity> response2 = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + assertTrue(response1.getStatusCode().is2xxSuccessful()); + assertTrue(response2.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ข‹์•„์š” ๋“ฑ๋ก์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ข‹์•„์š” ๋“ฑ๋ก์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "nonexist"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์ข‹์•„์š” ๋“ฑ๋ก์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenProductIdDoesNotExist() { + // arrange + Long nonExistentProductId = 99999L; + String url = ENDPOINT_LIKE_PRODUCTS + "/" + nonExistentProductId; + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); + } + } + + @DisplayName("DELETE /api/v1/like/products/{productId}") + @Nested + class DeleteLikeProduct { + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, `200 OK` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnOk_whenUnlikeProductSuccess() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = createHeaders(); + // ๋จผ์ € ์ข‹์•„์š” ๋“ฑ๋ก + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + // act + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์„ ์ทจ์†Œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") + @Test + void beIdempotent_whenUnlikeProductNotLiked() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ข‹์•„์š” ์ทจ์†Œ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.DELETE, new HttpEntity<>(null, null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ข‹์•„์š” ์ทจ์†Œ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "nonexist"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); + } + } + + @DisplayName("GET /api/v1/like/products") + @Nested + class GetLikedProducts { + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ 200 OK ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void returnLikedProducts_whenGetLikedProductsSuccess() { + // arrange + HttpHeaders headers = createHeaders(); + // ์ข‹์•„์š” ๋“ฑ๋ก + ParameterizedTypeReference> likeResponseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_LIKE_PRODUCTS + "/" + productId1, HttpMethod.POST, new HttpEntity<>(null, headers), likeResponseType); + testRestTemplate.exchange(ENDPOINT_LIKE_PRODUCTS + "/" + productId2, HttpMethod.POST, new HttpEntity<>(null, headers), likeResponseType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("ํŽ˜์ด์ง€๋„ค์ด์…˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ํ•ด๋‹น ํŽ˜์ด์ง€์˜ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnLikedProducts_whenWithPagination() { + // arrange + String url = ENDPOINT_LIKE_PRODUCTS + "?page=0&size=10"; + HttpHeaders headers = createHeaders(); + // ์ข‹์•„์š” ๋“ฑ๋ก + ParameterizedTypeReference> likeResponseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_LIKE_PRODUCTS + "/" + productId1, HttpMethod.POST, new HttpEntity<>(null, headers), likeResponseType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull() + ); + } + + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "nonexist"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..cbfaf772d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,828 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.vo.Price; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.product.Product; +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.vo.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.supply.SupplyJpaRepository; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.point.PointV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class OrderV1ApiE2ETest { + + private final String ENDPOINT_USER = "/api/v1/users"; + private final String ENDPOINT_POINT = "/api/v1/points"; + private final String ENDPOINT_ORDERS = "/api/v1/orders"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final SupplyJpaRepository supplyJpaRepository; + private final ProductMetricsJpaRepository productMetricsJpaRepository; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + SupplyJpaRepository supplyJpaRepository, + ProductMetricsJpaRepository productMetricsJpaRepository + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.supplyJpaRepository = supplyJpaRepository; + this.productMetricsJpaRepository = productMetricsJpaRepository; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private final String validUserId = "user123"; + private final String validEmail = "xx@yy.zz"; + private final String validBirthday = "1993-03-13"; + private final String validGender = "male"; + + private Long brandId; + private Long productId1; + private Long productId2; + private Long productId3; + + @BeforeEach + void setupUserAndProducts() { + // User ๋“ฑ๋ก + UserV1Dto.UserRegisterRequest userRequest = new UserV1Dto.UserRegisterRequest( + validUserId, + validEmail, + validBirthday, + validGender + ); + ParameterizedTypeReference> userResponseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(userRequest), userResponseType); + + // ํฌ์ธํŠธ ์ถฉ์ „ + HttpHeaders headers = createHeaders(); + PointV1Dto.PointChargeRequest pointRequest = new PointV1Dto.PointChargeRequest(100000); + ParameterizedTypeReference> pointResponseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, new HttpEntity<>(pointRequest, headers), pointResponseType); + + // Brand ๋“ฑ๋ก + Brand brand = Brand.create("Nike"); + Brand savedBrand = brandJpaRepository.save(brand); + brandId = savedBrand.getId(); + + // Product ๋“ฑ๋ก + Product product1 = createProduct("์ƒํ’ˆ1", brandId, 10000); + Product savedProduct1 = productJpaRepository.save(product1); + productId1 = savedProduct1.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); + productMetricsJpaRepository.save(metrics1); + + Product product2 = createProduct("์ƒํ’ˆ2", brandId, 20000); + Product savedProduct2 = productJpaRepository.save(product2); + productId2 = savedProduct2.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); + productMetricsJpaRepository.save(metrics2); + + Product product3 = createProduct("์ƒํ’ˆ3", brandId, 15000); + Product savedProduct3 = productJpaRepository.save(product3); + productId3 = savedProduct3.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics3 = ProductMetrics.create(productId3, 0); + productMetricsJpaRepository.save(metrics3); + + // Supply ๋“ฑ๋ก (์žฌ๊ณ  ์„ค์ •) + Supply supply1 = createSupply(productId1, 100); + supplyJpaRepository.save(supply1); + + Supply supply2 = createSupply(productId2, 50); + supplyJpaRepository.save(supply2); + + Supply supply3 = createSupply(productId3, 30); + supplyJpaRepository.save(supply3); + } + + private Product createProduct(String name, Long brandId, int priceAmount) { + return Product.create(name, brandId, new Price(priceAmount)); + } + + private Supply createSupply(Long productId, int stockQuantity) { + return Supply.create(productId, new Stock(stockQuantity)); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", validUserId); + return headers; + } + + @DisplayName("POST /api/v1/orders") + @Nested + class PostOrder { + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnOrderInfo_whenCreateOrderSuccess() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 1) + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().orderId()).isNotNull(), + () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(40000) + ); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•œ ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenStockInsufficient() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999) + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value() == 400).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์ฃผ๋ฌธํ•  ๊ฒฝ์šฐ, `404 Not Found` ๋˜๋Š” `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFoundOrBadRequest_whenProductIdDoesNotExist() { + // arrange + Long nonExistentProductId = 99999L; + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(nonExistentProductId, 1) + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + // Note: OrderFacade์—์„œ getProductMapByIds๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์€ ๋งต์— ํฌํ•จ๋˜์ง€ ์•Š์Œ + // ์ดํ›„ OrderItem.create์—์„œ productMap.get()์ด null์„ ๋ฐ˜ํ™˜ํ•˜๋ฉด NullPointerException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ + // ๋˜๋Š” SupplyService.checkAndDecreaseStock์—์„œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404 || response.getStatusCode().value() == 500).isTrue(); + } + + @DisplayName("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenPointInsufficient() { + // arrange + // ํฌ์ธํŠธ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉ + HttpHeaders headers = createHeaders(); + OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10) + ) + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); + + // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) + ) + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value() == 400).isTrue(); + } + + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "nonexist"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value() == 404).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenProductIdDoesNotExist() { + // arrange + Long nonExistentProductId = 99999L; + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(nonExistentProductId, 1) + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value() == 404).isTrue(); + } + + @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ค‘ ์ผ๋ถ€๋งŒ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenPartialStockInsufficient() { + // arrange + // productId1: ์žฌ๊ณ  100, productId2: ์žฌ๊ณ  50 + // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ + // ๊ฐœ์„  ํ›„์—๋Š” ๋ชจ๋“  ๋ถ€์กฑํ•œ ์ƒํ’ˆ์„ ํ•œ ๋ฒˆ์— ์•Œ๋ ค์ค„ ์ˆ˜ ์žˆ์Œ + } + + @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ชจ๋‘ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenAllProductsStockInsufficient() { + // arrange + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999), + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ + } + + @DisplayName("ํฌ์ธํŠธ๊ฐ€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void returnOrderInfo_whenPointExactlyMatches() { + // arrange + // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ + HttpHeaders headers = createHeaders(); + OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ + ) + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); + // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› + + // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์ฃผ๋ฌธ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› + ) + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(10000) + ); + } + + @DisplayName("์ค‘๋ณต ์ƒํ’ˆ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ, `500 Internal Server Error` ๋˜๋Š” `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnError_whenDuplicateProducts() { + // arrange + // ๊ฐ™์€ ์ƒํ’ˆ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ํฌํ•จ + // Note: Collectors.toMap()์€ ์ค‘๋ณต ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด IllegalStateException์„ ๋ฐœ์ƒ์‹œํ‚ด + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 3) // ์ค‘๋ณต + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + // Note: Collectors.toMap()์—์„œ ์ค‘๋ณต ํ‚ค๋กœ ์ธํ•ด IllegalStateException ๋ฐœ์ƒ + // ์ด๋Š” 500 Internal Server Error๋กœ ๋ณ€ํ™˜๋˜๊ฑฐ๋‚˜, 400 Bad Request๋กœ ์ฒ˜๋ฆฌ๋  ์ˆ˜ ์žˆ์Œ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 500).isTrue(); + } + + @DisplayName("Point ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž๋กœ ์ฃผ๋ฌธ ์‹œ๋„ ์‹œ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋œ๋‹ค.") + @Test + void returnNotFoundAndRollbackStock_whenPointDoesNotExist() { + // arrange + // Point๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž ์ƒ์„ฑ + String userWithoutPointId = "userWithoutPoint"; + UserV1Dto.UserRegisterRequest userRequest = new UserV1Dto.UserRegisterRequest( + userWithoutPointId, + "test2@test.com", + "1993-03-13", + "male" + ); + ParameterizedTypeReference> userResponseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(userRequest), userResponseType); + // Point๋Š” ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ + + // ์ดˆ๊ธฐ ์žฌ๊ณ  ํ™•์ธ + Supply initialSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); + int initialStock = initialSupply.getStock().quantity(); + + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", userWithoutPointId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + // ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋˜์–ด ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + Supply afterRollbackSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); + int afterStock = afterRollbackSupply.getStock().quantity(); + // ์žฌ๊ณ ๊ฐ€ ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ (์ดˆ๊ธฐ ์žฌ๊ณ ์™€ ๋™์ผํ•ด์•ผ ํ•จ) + assertThat(afterStock).isEqualTo(initialStock); + } + + @DisplayName("์žฌ๊ณ  ์ฐจ๊ฐ ํ›„ ํฌ์ธํŠธ ๋ถ€์กฑ ์‹œ, ๋กค๋ฐฑ๋˜์–ด ์žฌ๊ณ ๊ฐ€ ๋ณต๊ตฌ๋œ๋‹ค.") + @Test + void should_rollbackStock_whenPointInsufficientAfterStockDecrease() { + // arrange + // ์ดˆ๊ธฐ ์žฌ๊ณ  ํ™•์ธ + Supply initialSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); + int initialStock = initialSupply.getStock().quantity(); + + // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ + HttpHeaders headers = createHeaders(); + OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ + ) + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); + // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› + + // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ (์žฌ๊ณ ๋Š” ์ถฉ๋ถ„) + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2) // 20000์› ํ•„์š” (๋ถ€์กฑ) + ) + ); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + // ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋˜์–ด ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + Supply afterRollbackSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); + int afterStock = afterRollbackSupply.getStock().quantity(); + // ์ฒซ ์ฃผ๋ฌธ์—์„œ 9๊ฐœ ์ฐจ๊ฐ๋˜์—ˆ์œผ๋ฏ€๋กœ, ์ดˆ๊ธฐ ์žฌ๊ณ  - 9 = ํ˜„์žฌ ์žฌ๊ณ ์—ฌ์•ผ ํ•จ + assertThat(afterStock).isEqualTo(initialStock - 9); + } + + @DisplayName("๋ถ€๋ถ„ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ๋กค๋ฐฑ๋˜์–ด ์žฌ๊ณ ๊ฐ€ ๋ณต๊ตฌ๋œ๋‹ค.") + @Test + void should_rollbackStock_whenPartialStockInsufficient() { + // arrange + // ์ดˆ๊ธฐ ์žฌ๊ณ  ํ™•์ธ + Supply initialSupply1 = supplyJpaRepository.findByProductId(productId1).orElseThrow(); + int initialStock1 = initialSupply1.getStock().quantity(); + Supply initialSupply2 = supplyJpaRepository.findByProductId(productId2).orElseThrow(); + int initialStock2 = initialSupply2.getStock().quantity(); + + // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ + OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ + new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ + ) + ); + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + // ๋ชจ๋“  ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋˜์–ด ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + Supply afterRollbackSupply1 = supplyJpaRepository.findByProductId(productId1).orElseThrow(); + int afterStock1 = afterRollbackSupply1.getStock().quantity(); + Supply afterRollbackSupply2 = supplyJpaRepository.findByProductId(productId2).orElseThrow(); + int afterStock2 = afterRollbackSupply2.getStock().quantity(); + + assertThat(afterStock1).isEqualTo(initialStock1); + assertThat(afterStock2).isEqualTo(initialStock2); + } + } + + @DisplayName("GET /api/v1/orders") + @Nested + class GetOrderList { + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnOrderList_whenGetOrderListSuccess() { + // arrange + HttpHeaders headers = createHeaders(); + // ์ฃผ๋ฌธ ์ƒ์„ฑ + OrderV1Dto.OrderRequest orderRequest = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + ParameterizedTypeReference> orderResponseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), orderResponseType); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ todo ์ƒํƒœ์ด์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + } + + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "nonexist"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value() == 404).isTrue(); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + private Long orderId; + + @BeforeEach + void setupOrder() { + // ์ฃผ๋ฌธ ์ƒ์„ฑ + HttpHeaders headers = createHeaders(); + OrderV1Dto.OrderRequest orderRequest = new OrderV1Dto.OrderRequest( + List.of( + new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + ) + ); + ParameterizedTypeReference> orderResponseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> orderResponse = testRestTemplate.exchange( + ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), orderResponseType); + if (orderResponse.getStatusCode().is2xxSuccessful() && orderResponse.getBody() != null) { + orderId = orderResponse.getBody().data().orderId(); + } else { + orderId = 1L; // fallback + } + } + + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnOrderDetail_whenOrderExists() { + // arrange + String url = ENDPOINT_ORDERS + "/" + orderId; + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().is2xxSuccessful() || response.getStatusCode().value() == 404).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenOrderDoesNotExist() { + // arrange + Long nonExistentOrderId = 99999L; + String url = ENDPOINT_ORDERS + "/" + nonExistentOrderId; + HttpHeaders headers = createHeaders(); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + String url = ENDPOINT_ORDERS + "/" + orderId; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + String url = ENDPOINT_ORDERS + "/" + orderId; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + String url = ENDPOINT_ORDERS + "/" + orderId; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + String url = ENDPOINT_ORDERS + "/" + orderId; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "nonexist"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ + assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java index b4dd08e3c..fdca89097 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -39,35 +39,33 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private final String validUserId = "user123"; + private final String validEmail = "xx@yy.zz"; + private final String validBirthday = "1993-03-13"; + private final String validGender = "male"; + + @BeforeEach + void setupUser() { + UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( + validUserId, + validEmail, + validBirthday, + validGender + ); + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); + } + @DisplayName("GET /api/v1/points") @Nested class GetPoints { - private final String validUserId = "user123"; - private final String validEmail = "xx@yy.zz"; - private final String validBirthday = "1993-03-13"; - private final String validGender = "male"; - - // ํšŒ์›๊ฐ€์ž… ์ •๋ณด ์ž‘์„ฑ - @BeforeEach - void setupUser() { - UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( - validUserId, - validEmail, - validBirthday, - validGender - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); - } - - @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnUserPoints_whenGetUserPointsSuccess() { - // arrange: setupUser() ์ฐธ์กฐ - String xUserIdHeader = "user123"; + // arrange HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", xUserIdHeader); + headers.add("X-USER-ID", validUserId); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { @@ -82,11 +80,10 @@ void returnUserPoints_whenGetUserPointsSuccess() { ); } - //`X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - @DisplayName("`X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange: setupUser() ์ฐธ์กฐ + // arrange // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { @@ -98,20 +95,75 @@ void returnBadRequest_whenXUserIdHeaderIsMissing() { assertThat(response.getStatusCode().value()).isEqualTo(400); } - @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenUserIdDoesNotExist() { + // arrange + String invalidUserId = "nonexist"; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", invalidUserId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + } + + @DisplayName("POST /api/v1/points/charge") + @Nested + class ChargePoints { + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์ถฉ์ „์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnChargedPoints_whenChargeUserPointsSuccess() { - // arrange: setupUser() ์ฐธ์กฐ - String xUserIdHeader = "user123"; + // arrange HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", xUserIdHeader); + headers.add("X-USER-ID", validUserId); PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { }; HttpEntity requestEntity = new HttpEntity<>(request, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.POST, requestEntity, responseType); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); // assert assertAll( @@ -120,25 +172,75 @@ void returnChargedPoints_whenChargeUserPointsSuccess() { ); } - //์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์ถฉ์ „์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(request, null); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(request, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(request, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnNotFound_whenChargePointsForNonExistentUser() { - // arrange: setupUser() ์ฐธ์กฐ - String xUserIdHeader = "nonexist"; + // arrange + String invalidUserId = "nonexist"; HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", xUserIdHeader); + headers.add("X-USER-ID", invalidUserId); PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { }; HttpEntity requestEntity = new HttpEntity<>(request, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.POST, requestEntity, responseType); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); // assert assertThat(response.getStatusCode().value()).isEqualTo(404); } } - } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..55c80c579 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,265 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.common.vo.Price; +import com.loopers.domain.metrics.product.ProductMetrics; +import com.loopers.domain.product.Product; +import com.loopers.domain.supply.Supply; +import com.loopers.domain.supply.SupplyService; +import com.loopers.domain.supply.vo.Stock; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ProductV1ApiE2ETest { + + private final String ENDPOINT_PRODUCTS = "/api/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + private final BrandJpaRepository brandJpaRepository; + private final ProductJpaRepository productJpaRepository; + private final ProductMetricsJpaRepository productMetricsJpaRepository; +private final SupplyService supplyService; + + @Autowired + public ProductV1ApiE2ETest( + TestRestTemplate testRestTemplate, + DatabaseCleanUp databaseCleanUp, + BrandJpaRepository brandJpaRepository, + ProductJpaRepository productJpaRepository, + ProductMetricsJpaRepository productMetricsJpaRepository, + SupplyService supplyService + ) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + this.brandJpaRepository = brandJpaRepository; + this.productJpaRepository = productJpaRepository; + this.productMetricsJpaRepository = productMetricsJpaRepository; + this.supplyService = supplyService; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long brandId; + private Long productId1; + private Long productId2; + + @BeforeEach + void setupProducts() { + // Brand ๋“ฑ๋ก + Brand brand = Brand.create("Nike"); + Brand savedBrand = brandJpaRepository.save(brand); + brandId = savedBrand.getId(); + + // Product ๋“ฑ๋ก + Product product1 = createProduct("์ƒํ’ˆ1", brandId, 10000); + Product savedProduct1 = productJpaRepository.save(product1); + productId1 = savedProduct1.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); + productMetricsJpaRepository.save(metrics1); + // Supply ๋“ฑ๋ก + Supply supply1 = Supply.create(productId1, new Stock(10)); + supplyService.saveSupply(supply1); + + Product product2 = createProduct("์ƒํ’ˆ2", brandId, 20000); + Product savedProduct2 = productJpaRepository.save(product2); + productId2 = savedProduct2.getId(); + // ProductMetrics ๋“ฑ๋ก + ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); + productMetricsJpaRepository.save(metrics2); + // Supply ๋“ฑ๋ก + Supply supply2 = Supply.create(productId2, new Stock(20)); + supplyService.saveSupply(supply2); + } + + private Product createProduct(String name, Long brandId, int priceAmount) { + return Product.create(name, brandId, new Price(priceAmount)); + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetProductList { + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒํ’ˆ ๋ชฉ๋ก์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnProductList_whenGetProductListSuccess() { + // arrange + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().content()).isNotNull(), + () -> assertThat(response.getBody().data().size()).isGreaterThanOrEqualTo(2) + ); + } + + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒํ’ˆ ๋ชฉ๋ก์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnProductList_whenLoggedInUser() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", "user123"); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().content()).isNotNull(), + () -> assertThat(response.getBody().data().size()).isGreaterThanOrEqualTo(2) + ); + } + + @DisplayName("ํŽ˜์ด์ง€๋„ค์ด์…˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ํ•ด๋‹น ํŽ˜์ด์ง€์˜ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnProductList_whenWithPagination() { + // arrange + String url = ENDPOINT_PRODUCTS + "?page=0&size=10"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull() + ); + } + + @DisplayName("๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ๊ฐ€๊ฒฉ์ด ๋‚ฎ์€ ์ˆœ์œผ๋กœ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnProductList_whenSortedByPriceAsc() { + // arrange + String url = ENDPOINT_PRODUCTS + "?sort=price_asc"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + // ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ + () -> { + var products = response.getBody().data().content(); + for (int i = 0; i < products.size() - 1; i++) { + assertThat(products.get(i).price()).isLessThanOrEqualTo(products.get(i + 1).price()); + } + } + ); + } + + @DisplayName("์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ์ข‹์•„์š”๊ฐ€ ๋งŽ์€ ์ˆœ์œผ๋กœ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnProductList_whenSortedByLikesDesc() { + // arrange + String url = ENDPOINT_PRODUCTS + "?sort=like_desc"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + // ์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ + () -> { + var products = response.getBody().data().content(); + for (int i = 0; i < products.size() - 1; i++) { + assertThat(products.get(i).likes()).isGreaterThanOrEqualTo(products.get(i + 1).likes()); + } + } + ); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetProductDetail { + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnProductDetail_whenProductExists() { + // arrange + String url = ENDPOINT_PRODUCTS + "/" + productId1; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data()).isNotNull(), + () -> assertThat(response.getBody().data().id()).isEqualTo(productId1), + () -> assertThat(response.getBody().data().name()).isEqualTo("์ƒํ’ˆ1"), + () -> assertThat(response.getBody().data().price()).isEqualTo(10000) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNotFound_whenProductIdDoesNotExist() { + // arrange + Long nonExistentProductId = 99999L; + String url = ENDPOINT_PRODUCTS + "/" + nonExistentProductId; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + ResponseEntity> response = testRestTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index ebba3f89d..dc4df056b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -8,6 +8,7 @@ import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; @@ -66,7 +67,6 @@ void returnUserInfo_whenRegisterSuccess() { ); } - // ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnBadRequest_whenGenderIsMissing() { @@ -86,10 +86,9 @@ void returnBadRequest_whenGenderIsMissing() { // assert assertThat(response.getStatusCode().value()).isEqualTo(400); } - } - @DisplayName("GET /api/v1/users/{userId}") + @DisplayName("GET /api/v1/users/me") @Nested class Get { private final String validUserId = "user123"; @@ -97,7 +96,6 @@ class Get { private final String validBirthday = "1993-03-13"; private final String validGender = "male"; - // ํšŒ์›๊ฐ€์ž… ์ •๋ณด ์ž‘์„ฑ @BeforeEach void setupUser() { UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( @@ -111,14 +109,18 @@ void setupUser() { testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); } - @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnUserInfo_whenGetUserInfoSuccess() { - // arrange: setupUser() ์ฐธ์กฐ + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", validUserId); + // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { }; - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/" + validUserId, HttpMethod.GET, null, responseType); + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); // assert assertAll( @@ -130,16 +132,68 @@ void returnUserInfo_whenGetUserInfoSuccess() { ); } - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ๋‚ด ์ •๋ณด ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsMissing() { + // arrange + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, null); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsEmpty() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", ""); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnBadRequest_whenXUserIdHeaderIsBlank() { + // arrange + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", " "); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { + }; + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); + + // assert + assertThat(response.getStatusCode().value()).isEqualTo(400); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") @Test void returnNotFound_whenUserIdDoesNotExist() { // arrange String invalidUserId = "nonexist"; + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", invalidUserId); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { }; - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/" + invalidUserId, HttpMethod.GET, null, responseType); + HttpEntity requestEntity = new HttpEntity<>(null, headers); + ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); // assert assertThat(response.getStatusCode().value()).isEqualTo(404); From 5db5c362e7578969fc180e52e669b33bbd52b245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 14 Nov 2025 18:36:46 +0900 Subject: [PATCH 056/164] =?UTF-8?q?round3:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/common/vo/Money.java | 17 ----------------- .../com/loopers/domain/order/OrderItem_b.java | 14 -------------- 2 files changed, 31 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java deleted file mode 100644 index 8f3460701..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Money.java +++ /dev/null @@ -1,17 +0,0 @@ -//package com.loopers.domain.common.vo; -// -//import com.loopers.support.error.CoreException; -//import com.loopers.support.error.ErrorType; -// -//public record Money(int amount) { -// public Money add(Money other) { -// return new Money(this.amount + other.amount); -// } -// -// public Money subtract(Money other) { -// // defensive programming: prevent negative money amounts -// if (this.amount < other.amount) { -// throw new CoreException(ErrorType.BAD_REQUEST, ) -// } -// } -//} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java deleted file mode 100644 index ca3d76b1f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem_b.java +++ /dev/null @@ -1,14 +0,0 @@ -//package com.loopers.domain.order; -// -//import com.loopers.domain.common.vo.Price; -// -//public record OrderItem( -// Long productId, -// String productName, -// Integer quantity, -// Price price -//) { -// public Integer getTotalPrice() { -// return this.price.amount() * this.quantity; -// } -//} From aa374c35bb5ea1a8e6d26cf8c087aa8a8d99a20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Sat, 15 Nov 2025 11:25:58 +0900 Subject: [PATCH 057/164] =?UTF-8?q?round3:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/CommerceApiApplication.java | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index a32927655..62efd22b3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -10,7 +10,6 @@ @ConfigurationPropertiesScan @SpringBootApplication -@EnableJpaRepositories(basePackages = "com.loopers.domain") public class CommerceApiApplication { @PostConstruct From c74e6ad332d502d64848a163f59742d11fc72bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Sat, 15 Nov 2025 15:04:46 +0900 Subject: [PATCH 058/164] =?UTF-8?q?round3:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=82=B4=EB=A6=BC=EC=B0=A8=EC=88=9C=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 56 +++++++++++++++++++ .../product/ProductMetricsRepository.java | 6 +- .../product/ProductMetricsService.java | 12 ++++ .../domain/product/ProductService.java | 3 +- .../product/ProductMetricsRepositoryImpl.java | 7 +++ .../api/product/ProductV1Controller.java | 12 +--- 6 files changed, 82 insertions(+), 14 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index e5feac116..b87280ca3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -8,15 +8,21 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.supply.Supply; import com.loopers.domain.supply.SupplyService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -28,6 +34,14 @@ public class ProductFacade { @Transactional(readOnly = true) public Page getProductList(Pageable pageable) { + String sortStr = pageable.getSort().toString().split(":")[0]; + if (StringUtils.equals(sortStr, "like_desc")) { + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + Sort sort = Sort.by(Sort.Direction.DESC, "likeCount"); + return getProductsByLikeCount(PageRequest.of(page, size, sort)); + } + Page products = productService.getProducts(pageable); List productIds = products.map(Product::getId).toList(); @@ -39,8 +53,50 @@ public Page getProductList(Pageable pageable) { return products.map(product -> { ProductMetrics metrics = metricsMap.get(product.getId()); + if (metrics == null) { + throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ฉ”ํŠธ๋ฆญ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + Supply supply = supplyMap.get(product.getId()); + if (supply == null) { + throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + return new ProductInfo( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice().amount(), + metrics.getLikeCount(), + supply.getStock().quantity() + ); + }); + } + + public Page getProductsByLikeCount(Pageable pageable) { + Page metricsPage = productMetricsService.getMetrics(pageable); + List productIds = metricsPage.map(ProductMetrics::getProductId).toList(); + Map productMap = productService.getProductMapByIds(productIds); + Set brandIds = productMap.values().stream().map(Product::getBrandId).collect(Collectors.toSet()); + Map brandMap = brandService.getBrandMapByBrandIds(brandIds); + Map supplyMap = supplyService.getSupplyMapByProductIds(productIds); + + return metricsPage.map(metrics -> { + Product product = productMap.get(metrics.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } Supply supply = supplyMap.get(product.getId()); + if (supply == null) { + throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } return new ProductInfo( product.getId(), diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java index 64b8d5c75..c9f236182 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java @@ -1,11 +1,15 @@ package com.loopers.domain.metrics.product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import java.util.Collection; -import java.util.List; import java.util.Optional; public interface ProductMetricsRepository { Optional findByProductId(Long productId); Collection findByProductIds(Collection productIds); + + Page findAll(Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java index 4975f177c..d39f95c9f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java @@ -3,6 +3,8 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import java.util.Collection; @@ -24,4 +26,14 @@ public Map getMetricsMapByProductIds(Collection prod .stream() .collect(Collectors.toMap(ProductMetrics::getProductId, metrics -> metrics)); } + + // pageable like_count ์š”๊ฑด์— ๋”ฐ๋ผ ์ •๋ ฌ๋œ ์ƒ์œ„ N๊ฐœ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์กฐํšŒ + public Page getMetrics(Pageable pageable) { + // ํ˜„์žฌ๋Š” like_count, desc๋งŒ ๊ฐ€์ง€๋ฏ€๋กœ, ์˜ˆ์™ธ์ฒ˜๋ฆฌ ํ•„์š” + String sortString = pageable.getSort().toString(); + if (!sortString.equals("likeCount: DESC")) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ •๋ ฌ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค."); + } + return productMetricsRepository.findAll(pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 38fe96076..9a4bf8842 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -35,10 +35,9 @@ public Page getProducts(Pageable pageable) { int size = pageable.getPageSize(); String sortStr = pageable.getSort().toString().split(":")[0]; Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); + if (StringUtils.startsWith(sortStr, "price_asc")) { sort = Sort.by(Sort.Direction.ASC, "price"); - } else if (StringUtils.equals(sortStr, "like_desc")) { - sort = Sort.by(Sort.Direction.DESC, "like_count"); } return productRepository.findAll(PageRequest.of(page, size, sort)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java index db76a1d92..3fb0ec2ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java @@ -3,6 +3,8 @@ import com.loopers.domain.metrics.product.ProductMetrics; import com.loopers.domain.metrics.product.ProductMetricsRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import java.util.Collection; @@ -22,4 +24,9 @@ public Optional findByProductId(Long productId) { public Collection findByProductIds(Collection productIds) { return jpaRepository.findAllById(productIds); } + + @Override + public Page findAll(Pageable pageable) { + return jpaRepository.findAll(pageable); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 9963fca1c..6597f8d5d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -24,17 +24,7 @@ public class ProductV1Controller implements ProductV1ApiSpec { @RequestMapping(method = RequestMethod.GET) @Override public ApiResponse getProductList(@PageableDefault(size = 20) Pageable pageable) { - int page = pageable.getPageNumber(); - int size = pageable.getPageSize(); - String sortStr = pageable.getSort().toString().split(":")[0]; - Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); - if (StringUtils.equals(sortStr, "price_asc")) { - sort = Sort.by(Sort.Direction.ASC, "price"); - } else if (StringUtils.equals(sortStr, "like_desc")) { - sort = Sort.by(Sort.Direction.DESC, "like_count"); - } - - Page products = productFacade.getProductList(PageRequest.of(page, size, sort)); + Page products = productFacade.getProductList(pageable); ProductV1Dto.ProductsPageResponse response = ProductV1Dto.ProductsPageResponse.from(products); return ApiResponse.success(response); } From c80ed47e79feb6eb0077524eced0a16070811b7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Sat, 15 Nov 2025 15:06:33 +0900 Subject: [PATCH 059/164] =?UTF-8?q?round3:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EC=84=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/week01.md | 289 ------------------------------------------ docs/week01_quests.md | 131 ------------------- 2 files changed, 420 deletions(-) delete mode 100644 docs/week01.md delete mode 100644 docs/week01_quests.md diff --git a/docs/week01.md b/docs/week01.md deleted file mode 100644 index decb1ea16..000000000 --- a/docs/week01.md +++ /dev/null @@ -1,289 +0,0 @@ -# ๐Ÿงญ ๋ฃจํ”„ํŒฉ BE L2 - Round 1 - -> ๋‹จ์ˆœํžˆ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒŒ ์•„๋‹ˆ๋ผ, ์˜๋„๋ฅผ ์„ค๊ณ„ํ•œ๋‹ค. -> - - - -- ๊ธฐ๋Šฅ ๊ตฌํ˜„๋ณด๋‹ค ๋จผ์ € ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ณธ๋‹ค. -- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ž€ ๋ฌด์—‡์ธ์ง€ ์ฒด๊ฐํ•ด๋ณธ๋‹ค. -- ์œ ์ € ๋“ฑ๋ก/์กฐํšŒ, ํฌ์ธํŠธ ์ถฉ์ „ ๊ธฐ๋Šฅ์„ ํ…Œ์ŠคํŠธ ์ฃผ๋„๋กœ ๊ตฌํ˜„ํ•ด๋ณธ๋‹ค. - - - -- ๋‹จ์œ„ ํ…Œ์ŠคํŠธ vs ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ -- ํ…Œ์ŠคํŠธ ๋”๋ธ”(Mock, Stub, Fake ๋“ฑ) -- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ์ฝ”๋“œ ๊ตฌ์กฐ -- ํ…Œ์ŠคํŠธ ์ฃผ๋„ ๊ฐœ๋ฐœ (TDD) - - - -## ๐Ÿงช ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ - -> ํ…Œ์ŠคํŠธ๋Š” ์•„๋ž˜์ฒ˜๋Ÿผ **๋ฒ”์œ„์— ๋”ฐ๋ผ ์—ญํ• ๊ณผ ์ฑ…์ž„์ด ๋‚˜๋‰˜๋ฉฐ**, -ํ•˜๋‹จ์ผ์ˆ˜๋ก ๋น ๋ฅด๊ณ  ๋งŽ์ด, ์ƒ๋‹จ์ผ์ˆ˜๋ก ๋А๋ฆฌ์ง€๋งŒ ์‹ ์ค‘ํ•˜๊ฒŒ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค. -> - -![Untitled](attachment:54f631d6-538a-44fa-8358-026c73efed68:Untitled.png) - -### ๐Ÿงฑ 1. **๋‹จ์œ„ ํ…Œ์ŠคํŠธ (Unit Test)** - -- **๋Œ€์ƒ:** ๋„๋ฉ”์ธ ๋ชจ๋ธ (Entity, VO, Domain Service) -- **๋ชฉ์ :** ์ˆœ์ˆ˜ ๋กœ์ง์˜ ์ •ํ•ฉ์„ฑ๊ณผ ๊ทœ์น™ ๊ฒ€์ฆ -- **ํ™˜๊ฒฝ:** Spring ์—†์ด ์ˆœ์ˆ˜ JVM์—์„œ ์‹คํ–‰ (JVM ๋‹จ์œ„ ํ…Œ์ŠคํŠธ) / **ํ…Œ์ŠคํŠธ ๋Œ€์—ญ** ์„ ํ™œ์šฉํ•ด ๋ชจ๋“  ์˜์กด์„ฑ์„ ๋Œ€์ฒด -- **๊ธฐ์ˆ :** JUnit5, Kotest, AssertJ ๋“ฑ - -> ๐Ÿ’ฌ ์˜ˆ: ํฌ์ธํŠธ ์ถฉ์ „ ์‹œ ์ตœ๋Œ€ ํ•œ๋„ ์ดˆ๊ณผ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ํ…Œ์ŠคํŠธ -> - -### ๐Ÿ” 2. **ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ (Integration Test)** - -- **๋Œ€์ƒ:** ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ Service, Facade ๋“ฑ ๊ณ„์ธต ๋กœ์ง -- **๋ชฉ์ :** ์—ฌ๋Ÿฌ ์ปดํฌ๋„ŒํŠธ(Repo, Domain, ์™ธ๋ถ€ API Stub)๊ฐ€ ์—ฐ๊ฒฐ๋œ ์ƒํƒœ์—์„œ **๋น„์ฆˆ๋‹ˆ์Šค ํ๋ฆ„ ์ „์ฒด๋ฅผ ๊ฒ€์ฆ** -- **ํ™˜๊ฒฝ:** `@SpringBootTest`, ์‹ค์ œ Bean ๊ตฌ์„ฑ, Test DB -- **๊ธฐ์ˆ :** SpringBootTest + H2 + TestContainers ๋“ฑ - -> ๐Ÿ’ฌ ์˜ˆ: ์‹ค์ œ ํฌ์ธํŠธ๊ฐ€ ์ถฉ์ „๋˜๊ณ , DB์— ๋ฐ˜์˜๋˜๋ฉฐ, ์ด๋ฒคํŠธ๊ฐ€ ๋ฐœํ–‰๋˜๋Š” ์ „ ๊ณผ์ •์„ ๊ฒ€์ฆ -> - -### ๐ŸŒ 3. **E2E ํ…Œ์ŠคํŠธ (End-to-End Test)** - -- **๋Œ€์ƒ:** ์ „์ฒด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ (Controller โ†’ Service โ†’ DB) -- **๋ชฉ์ :** ์‹ค์ œ HTTP ์š”์ฒญ ๋‹จ์œ„ ์‹œ๋‚˜๋ฆฌ์˜ค ํ…Œ์ŠคํŠธ -- **ํ™˜๊ฒฝ:** `MockMvc` ๋˜๋Š” `TestRestTemplate`์„ ํ†ตํ•ด ์‹ค์ œ API ์š”์ฒญ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ -- **๊ธฐ์ˆ :** SpringBootTest + `@AutoConfigureMockMvc`, `WebTestClient` ๋“ฑ - -> ๐Ÿ’ฌ ์˜ˆ: ์‚ฌ์šฉ์ž๊ฐ€ ํšŒ์›๊ฐ€์ž… โ†’ ํฌ์ธํŠธ ์ถฉ์ „ โ†’ ์ฃผ๋ฌธ ํ๋ฆ„์„ HTTP ์š”์ฒญ์œผ๋กœ ์ˆ˜ํ–‰ํ–ˆ์„ ๋•Œ์˜ ๊ฒฐ๊ณผ ํ™•์ธ -> - ---- - -## ๐Ÿ”ง ํ…Œ์ŠคํŠธ ๋”๋ธ”(Test Doubles) - -> ํ…Œ์ŠคํŠธ ๋Œ€์ƒ์ด ์˜์กดํ•˜๋Š” ์™ธ๋ถ€ ๊ฐ์ฒด์˜ ๋™์ž‘์„ **๋น ๋ฅด๊ณ  ์•ˆ์ „ํ•˜๊ฒŒ ํ‰๋‚ด ๋‚ด๋Š” ๋Œ€์—ญ ๊ฐ์ฒด** ์ž…๋‹ˆ๋‹ค. -๋А๋ฆฌ๊ณ  ๋ถˆ์•ˆ์ •ํ•œ ์‹ค์ œ ๊ตฌํ˜„ ๋Œ€์‹ , ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์— ๋งž๋Š” **โ€˜์กฐ์šฉํ•œ ๋Œ€์—ญโ€™** ์„ ์„ธ์›Œ์ค๋‹ˆ๋‹ค. -> - -### ๐Ÿงฉ ํ…Œ์ŠคํŠธ ๋”๋ธ”์€ ์—ญํ• , `mock()`๊ณผ `spy()`๋Š” ๋„๊ตฌ - -- `Stub`, `Mock`, `Spy`, `Fake` ๋Š” **ํ…Œ์ŠคํŠธ ๋ชฉ์  (์—ญํ• )** -- `mock()`, `spy()`๋Š” **๊ฐ์ฒด ์ƒ์„ฑ ๋ฐฉ์‹ (๋„๊ตฌ)** - -e.g. - -```kotlin -val repo = mock() // ๋„๊ตฌ: mock() -whenever(repo.findById(1L)).thenReturn(User(...)) // ์—ญํ• : Stub -verify(repo).findById(1L) // ์—ญํ• : Mock -``` - -> โœ… mock ๊ฐ์ฒด์— stub + mock ์—ญํ• ์„ ๋™์‹œ์— ๋ถ€์—ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. -> - -### ๐Ÿ“š TestDouble ์—ญํ• ๋ณ„ ์ •๋ฆฌ - -| ์—ญํ•  | ๋ชฉ์  | ์‚ฌ์šฉ ๋ฐฉ์‹ | ์˜ˆ์‹œ | -| --- | --- | --- | --- | -| **Dummy** | ์ž๋ฆฌ๋งŒ ์ฑ„์›€ (์‚ฌ์šฉ๋˜์ง€ ์•Š์Œ) | ์ƒ์„ฑ์ž ๋“ฑ์—์„œ ์ „๋‹ฌ | `User(null, null)` | -| **Stub** | ๊ณ ์ •๋œ ์‘๋‹ต ์ œ๊ณต (์ƒํƒœ ๊ธฐ๋ฐ˜) | `when().thenReturn()` | `repo.find()` โ†’ ํ•ญ์ƒ ํŠน์ • ์œ ์ € ๋ฐ˜ํ™˜ | -| **Mock** | ํ˜ธ์ถœ ์—ฌ๋ถ€/ํšŸ์ˆ˜ ๊ฒ€์ฆ (ํ–‰์œ„ ๊ธฐ๋ฐ˜) | `verify(...)` | ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋˜์—ˆ๋Š”์ง€ ๊ฒ€์ฆ | -| **Spy** | ์ง„์งœ ๊ฐ์ฒด ๊ฐ์‹ธ๊ธฐ + ์ผ๋ถ€ ์กฐ์ž‘ | `spy()` + `doReturn()` | ์ง„์งœ ์„œ๋น„์Šค ๊ฐ์‹ธ๊ณ  ์ผ๋ถ€๋งŒ stub | -| **Fake** | ์‹ค์ œ์ฒ˜๋Ÿผ ๋™์ž‘ํ•˜๋Š” ๊ฐ€์งœ ๊ตฌํ˜„์ฒด | ์ง์ ‘ ํด๋ž˜์Šค ๊ตฌํ˜„ | **InMemoryUserRepository** | - -### ๐Ÿ” TestDouble ์‹ค์ „ ์˜ˆ์ œ - -### ๐Ÿ“ฆ Stub ์˜ˆ์ œ - -```kotlin -val userRepo = mock() -whenever(userRepo.findById(1L)).thenReturn(User("alen")) -``` - -- ํ๋ฆ„๋งŒ ํ†ต์ œํ•˜๊ณ  ์‹ถ์€ ๊ฒฝ์šฐ -- โ€œ์ด๋ ‡๊ฒŒ ํ˜ธ์ถœํ•˜๋ฉด, ์ด๋ ‡๊ฒŒ ์‘๋‹ตํ•ด์ค˜โ€ - -### ๐Ÿ“ฌ Mock ์˜ˆ์ œ - -```kotlin -val speaker = mock() -speaker.say("hello") -verify(speaker, times(1)).say("hello") -``` - -- ํ˜ธ์ถœ ์—ฌ๋ถ€๊ฐ€ ๊ฒ€์ฆ ๋Œ€์ƒ -- โ€œ๋„ˆ ์ด๋ ‡๊ฒŒ ๋™์ž‘ํ–ˆ๋‹ˆ?โ€ - -### ๐Ÿ•ต๏ธ Spy ์˜ˆ์ œ - -```kotlin -val friend = Friend() -val spyFriend = spy(friend) -spyFriend.hangout() -verify(spyFriend).hangout() -``` - -- ์ง„์งœ ๊ฐ์ฒด์ฒ˜๋Ÿผ ๋™์ž‘ํ•˜๋ฉด์„œ ์ผ๋ถ€๋งŒ ์กฐ์ž‘ -- "๋กœ์ง์€ ๊ทธ๋Œ€๋กœ ์“ฐ๊ณ , ํŠน์ • ๋™์ž‘๋งŒ ๋ฎ์–ด์”Œ์šฐ๊ณ  / ๊ฒ€์ฆํ•˜๊ณ  ์‹ถ๋‹ค" - -### ๐Ÿงช Fake ์˜ˆ์ œ - -```kotlin -class InMemoryUserRepository : UserRepository { - private val data = mutableMapOf() - - override fun save(user: User) { data[user.id] = user } - override fun findById(id: Long): User? = data[user.id] -} -``` - -- ์‹ค์ œ DB ์—†์ด ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ์ €์žฅ์†Œ ๊ตฌํ˜„ -- "์™„์ „ํžˆ ๋…๋ฆฝ์ ์ธ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์ด ํ•„์š”ํ•  ๋•Œโ€ - ---- - -## ๐Ÿงฑ ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ - -> **๊ฒ€์ฆํ•˜๊ณ  ์‹ถ์€ ๋กœ์ง์„, ์™ธ๋ถ€ ์˜์กด์„ฑ๊ณผ ๊ฒฉ๋ฆฌ๋œ ์ƒํƒœ์—์„œ ๋‹จ๋…์œผ๋กœ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ๋Š” ๊ตฌ์กฐ**์ž…๋‹ˆ๋‹ค. -> -> -> ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ž€, ๊ฒ€์ฆํ•˜๊ณ  ์‹ถ์€ ์ฝ”๋“œ๋งŒ ์ •ํ™•ํžˆ ๊บผ๋‚ด์„œ **์กฐ์šฉํ•˜๊ณ  ๋‹จ๋‹จํ•˜๊ฒŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ตฌ์กฐ**๋‹ค. -> - -### โŒ ํ…Œ์ŠคํŠธํ•˜๊ธฐ ์–ด๋ ค์šด ๊ตฌ์กฐ์˜ ํŠน์ง• - -| ๋ฌธ์ œ | ์„ค๋ช… | -| --- | --- | -| **๋‚ด๋ถ€์—์„œ ์˜์กด ๊ฐ์ฒด ์ง์ ‘ ์ƒ์„ฑ (`new`)** | ํ…Œ์ŠคํŠธ ๋Œ€์—ญ์œผ๋กœ ๋Œ€์ฒด ๋ถˆ๊ฐ€ โ†’ ํ…Œ์ŠคํŠธ ๊ฒฉ๋ฆฌ ๋ถˆ๊ฐ€๋Šฅ | -| **ํ•˜๋‚˜์˜ ํ•จ์ˆ˜๊ฐ€ ๋„ˆ๋ฌด ๋งŽ์€ ์ฑ…์ž„** | ํ…Œ์ŠคํŠธ ๋Œ€์ƒ์ด ๋ชจํ˜ธํ•ด์ง โ†’ ์‹คํŒจ ์›์ธ ์ถ”์  ์–ด๋ ค์›€ | -| **์™ธ๋ถ€ API ํ˜ธ์ถœ, DB ์ ‘๊ทผ ๋“ฑ์ด ํ•˜๋“œ์ฝ”๋”ฉ** | ์‹ค์ œ ํ™˜๊ฒฝ ์—†์ด ํ…Œ์ŠคํŠธ ๋ถˆ๊ฐ€๋Šฅ โ†’ ๋А๋ฆฌ๊ณ  ๋ถˆ์•ˆ์ • | -| **private ๋กœ์ง, static ๋ฉ”์„œ๋“œ ๋‚จ์šฉ** | ์™ธ๋ถ€์—์„œ ๋กœ์ง ๋ถ„๋ฆฌ ๋ถˆ๊ฐ€ โ†’ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ ๋ถˆ๊ฐ€ | - -### โœ… ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ๋ณ€๊ฒฝ - -| ํฌ์ธํŠธ | ์„ค๋ช… | -| --- | --- | -| **์™ธ๋ถ€ ์˜์กด์„ฑ ๋ถ„๋ฆฌ** | ์ธํ„ฐํŽ˜์ด์Šคํ™” + ์ƒ์„ฑ์ž ์ฃผ์ž…(DI) | -| **๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ถ„๋ฆฌ** | ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ or ์ „์šฉ Service์—์„œ ์ฑ…์ž„ ๋ถ„์‚ฐ | -| **์ฑ…์ž„ ๋‹จ์ผํ™”** | ํ•œ ํ•จ์ˆ˜๋Š” ํ•œ ์—ญํ• ๋งŒ (e.g. ๊ฒฐ์ œ๋งŒ, ์žฌ๊ณ ๋งŒ ๋“ฑ) | -| **์ƒํƒœ ์ค‘์‹ฌ ์„ค๊ณ„** | โ€œ์ž…๋ ฅ โ†’ ์ƒํƒœ ๋ณ€ํ™” โ†’ ๊ฒฐ๊ณผโ€ ๊ตฌ์กฐ๋กœ ์ •๋ฆฌ | - -### ๐Ÿ” ์‚ฌ๋ก€๋กœ ์‚ดํŽด๋ณด๊ธฐ - -```kotlin -class OrderService { - fun completeOrder(userId: Long, productId: Long) { - val user = UserJpaRepository().findById(userId) - val product = ProductJpaRepository().findById(productId) - - if (product.stock <= 0) throw IllegalStateException() - product.stock-- - - if (user.point < product.price) throw IllegalStateException() - user.point -= product.price - - OrderRepository().save(Order(user, product)) - } -} -``` - -- ์™ธ๋ถ€ ์˜์กด์„ฑ ์ง์ ‘ ์ƒ์„ฑ โ†’ Mock/Fake ๋ถˆ๊ฐ€ -- ๋„๋ฉ”์ธ ๋กœ์ง, ์ƒํƒœ๋ณ€๊ฒฝ, ์™ธ๋ถ€ ํ˜ธ์ถœ์ด ํ•œ ๊ณณ์— ๋ชฐ๋ ค ์žˆ์Œ -- `OrderServiceTest` ํ•˜๋‚˜๋กœ ๋ชจ๋“  ์ผ€์ด์Šค ์ปค๋ฒ„ํ•ด์•ผ ํ•จ โ†’ ์‹คํŒจ ์‹œ ์–ด๋””์„œ ์ž˜๋ชป๋๋Š”์ง€ ์ถ”์  ๋ถˆ๊ฐ€ - ---- - -```kotlin -class OrderService( - private val userReader: UserReader, - private val productReader: ProductReader, - private val orderRepository: OrderRepository, -) { - fun completeOrder(command: OrderCommand) { - val user = userReader.get(command.userId) - val product = productReader.get(command.productId) - - product.decreaseStock() - user.pay(product.price) - - orderRepository.save(Order(user, product)) - } -} -``` - -- ์™ธ๋ถ€๋Š” ์ธํ„ฐํŽ˜์ด์Šค๋กœ ์ฃผ์ž… โ†’ Fake/Mock ๊ฐ€๋Šฅ -- ๋กœ์ง์€ `user.pay()`, `product.decreaseStock()` ์ฒ˜๋Ÿผ ๋„๋ฉ”์ธ์œผ๋กœ ์œ„์ž„ -- ํ…Œ์ŠคํŠธ ๋‹จ์œ„๋ณ„๋กœ ๋‚˜๋ˆŒ ์ˆ˜ ์žˆ์Œ โ†’ `UserTest`, `ProductTest`, `OrderServiceTest` - ---- - -## ๐Ÿ” TDD (Test-Driven Development) - -> TDD๋Š” ํ…Œ์ŠคํŠธ์˜ ์ˆœ์„œ๋ณด๋‹ค -**โ€์„ค๊ณ„ ๋‹จ์œ„๋ฅผ ์ž˜๊ฒŒ ์ชผ๊ฐœ๊ณ , ๊ทธ๊ฒƒ์ด ๊ฒ€์ฆ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ๋Š”๊ฐ€โ€**๊ฐ€ ํ•ต์‹ฌ์ด๋‹ค. -> - -### ๐Ÿ”„ 3๋‹จ๊ณ„ ๋ฃจํ”„: Red โ†’ Green โ†’ Refactor - -``` -< ๋ฐ˜๋ณต > -1. ์‹คํŒจํ•˜๋Š” ํ…Œ์ŠคํŠธ ์ž‘์„ฑ (Red) -2. ํ†ต๊ณผํ•  ์ตœ์†Œํ•œ์˜ ์ฝ”๋“œ ์ž‘์„ฑ (Green) -3. ๊ตฌ์กฐ ๊ฐœ์„  ๋ฐ ๋ฆฌํŒฉํ† ๋ง (Refactor) -``` - -### ๐Ÿง  ๊ทธ๋Ÿฐ๋ฐ ๊ผญ ํ…Œ์ŠคํŠธ๋ฅผ ๋จผ์ € ์จ์•ผ ํ• ๊นŒ? - -| **์ „๋žต** | **์ด๋ฆ„** | **์„ค๋ช…** | -| --- | --- | --- | -| ๐Ÿงช TFD (Test First Development) | ํ…Œ์ŠคํŠธ ๋จผ์ € ์ž‘์„ฑ โ†’ ์ฝ”๋“œ๋ฅผ ๋งž์ถฐ ๊ตฌํ˜„ | ๋„๋ฉ”์ธ/๋กœ์ง ์ค‘์‹ฌ์— ์ ํ•ฉ | -| ๐Ÿ— TLD (Test Last Development) | ์ฝ”๋“œ๋ฅผ ๋จผ์ € ์ž‘์„ฑ โ†’ ํ…Œ์ŠคํŠธ๋Š” ๋‚˜์ค‘์— ์ž‘์„ฑ | API/๊ณ„์ธต ์„ค๊ณ„๊ฐ€ ๋จผ์ € ํ•„์š”ํ•œ ์ƒํ™ฉ์— ์ ํ•ฉ | - -### ๐ŸŸข TDD๊ฐ€ ํ•„์š”ํ•œ ์ด์œ  - -- **์š”๊ตฌ์‚ฌํ•ญ์„ ๋จผ์ € ์ •๋ฆฌํ•  ์ˆ˜ ์žˆ๋‹ค** -- **์ž‘๊ฒŒ ์ชผ๊ฐœ๊ณ  ์ ์ง„์ ์œผ๋กœ ์„ค๊ณ„ํ•˜๊ฒŒ ๋œ๋‹ค** -- **์ธํ„ฐํŽ˜์ด์Šค ์„ค๊ณ„๊ฐ€ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋‚˜์˜จ๋‹ค** -- **๋ฆฌํŒฉํ† ๋ง์ด ๊ฐ€๋Šฅํ•ด์ง„๋‹ค** - - - -| ๊ตฌ๋ถ„ | ๋งํฌ | -| --- | --- | -| ๐Ÿ”ข ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ | [Testing Pyramid - Martin Fowler](https://martinfowler.com/bliki/TestPyramid.html) | -| ๐Ÿงช JUnit5 | [JUnit5 ๊ณต์‹ ๋ฌธ์„œ](https://junit.org/junit5/docs/current/user-guide/) | -| โš™๏ธ Mockito | [Mockito ๊ณต์‹ ๋ฌธ์„œ](https://site.mockito.org/) | -| ๐Ÿงฐ Mockito-Kotlin | [GitHub: mockito-kotlin](https://github.com/mockito/mockito-kotlin) | -| ๐Ÿงต Spring ํ…Œ์ŠคํŠธ | [Spring Boot Testing Guide](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing) | - -> ๋ณธ ๊ณผ์ •์—์„œ๋Š” ์›ํ™œํ•œ ๋ฉ˜ํ† ๋ง์„ ์œ„ํ•ด `JUnit5 + Mockito` ๊ธฐ๋ฐ˜์œผ๋กœ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค. -> - - - -> ๋‹ค์Œ ์ฃผ์—๋Š” ๋ณธ๊ฒฉ์ ์œผ๋กœ ์šฐ๋ฆฌ๋งŒ์˜ e-commerce ์‹œ์Šคํ…œ์„ **์„ค๊ณ„** ํ•ด๋ด…๋‹ˆ๋‹ค. -> \ No newline at end of file diff --git a/docs/week01_quests.md b/docs/week01_quests.md deleted file mode 100644 index 9d6ae8170..000000000 --- a/docs/week01_quests.md +++ /dev/null @@ -1,131 +0,0 @@ - - -# ๐Ÿ“ Round 1 Quests - ---- - -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. -> - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [ ] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [ ] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [ ] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์ถฉ์ „ - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [ ] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [ ] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [ ] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -## โœ… Checklist - -- [ ] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ -- [ ] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ -- [ ] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ - ---- - -## โœ๏ธ Technical Writing Quest - -> ์ด๋ฒˆ ์ฃผ์— ํ•™์Šตํ•œ ๋‚ด์šฉ, ๊ณผ์ œ ์ง„ํ–‰์„ ๋˜๋Œ์•„๋ณด๋ฉฐ -**"๋‚ด๊ฐ€ ์–ด๋–ค ํŒ๋‹จ์„ ํ•˜๊ณ  ์™œ ๊ทธ๋ ‡๊ฒŒ ๊ตฌํ˜„ํ–ˆ๋Š”์ง€"** ๋ฅผ ๊ธ€๋กœ ์ •๋ฆฌํ•ด๋ด…๋‹ˆ๋‹ค. -> -> -> **์ข‹์€ ๋ธ”๋กœ๊ทธ ๊ธ€์€ ๋‚ด๊ฐ€ ๊ฒช์€ ๋ฌธ์ œ๋ฅผ, ํƒ€์ธ๋„ ๊ณต๊ฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ์ •๋ฆฌํ•œ ๊ธ€์ž…๋‹ˆ๋‹ค.** -> -> ์ด ๊ธ€์€ ๋‹จ์ˆœ ๊ณผ์ œ๊ฐ€ ์•„๋‹ˆ๋ผ, **ํ–ฅํ›„ ์ด์ง์— ๋„์›€์ด ๋  ์ˆ˜ ์žˆ๋Š” ํฌํŠธํด๋ฆฌ์˜ค** ๊ฐ€ ๋  ์ˆ˜ ์žˆ์–ด์š”. -> - -### ๐Ÿ“š Technical Writing Guide - -### โœ… ์ž‘์„ฑ ๊ธฐ์ค€ - -| ํ•ญ๋ชฉ | ์„ค๋ช… | -| --- | --- | -| **ํ˜•์‹** | ๋ธ”๋กœ๊ทธ | -| **๊ธธ์ด** | ์ œํ•œ ์—†์Œ, ๋‹จ ๊ผญ **1์ค„ ์š”์•ฝ (TL;DR)** ์„ ํฌํ•จํ•ด ์ฃผ์„ธ์š” | -| **ํฌ์ธํŠธ** | โ€œ๋ฌด์—‡์„ ํ–ˆ๋‹คโ€ ๋ณด๋‹ค **โ€œ์™œ ๊ทธ๋ ‡๊ฒŒ ํŒ๋‹จํ–ˆ๋Š”๊ฐ€โ€** ์ค‘์‹ฌ | -| **์˜ˆ์‹œ ํฌํ•จ** | ์ฝ”๋“œ ๋น„๊ต, ํ๋ฆ„๋„, ๋ฆฌํŒฉํ† ๋ง ์ „ํ›„ ์˜ˆ์‹œ ๋“ฑ ์ž์œ ๋กญ๊ฒŒ | -| **ํ†ค** | ์‹ค๋ ฅ์€ ๋ณด์ด์ง€๋งŒ, ์ž๋งŒํ•˜์ง€ ์•Š๊ณ , **๊ณ ๋ฏผ์ด ์ฝํžˆ๋Š” ๊ธ€**์˜ˆ: โ€œ์ฒ˜์Œ์—” mock์œผ๋กœ ์ถฉ๋ถ„ํ•˜๋‹ค๊ณ  ์ƒ๊ฐํ–ˆ์ง€๋งŒ, ๋‚˜์ค‘์— fake๋กœ ๊ต์ฒดํ•˜๊ฒŒ ๋œ ์ด์œ ๋Š”โ€ฆโ€ | - ---- - -### โœจ ์ข‹์€ ํ†ค์€ ์ด๋Ÿฐ ๋А๋‚Œ์ด์—์š” - -> ๋‚ด๊ฐ€ ๊ฒช์€ ์‹ค์ „์  ๊ณ ๋ฏผ์„ ๋‹ค๋ฅธ ๊ฐœ๋ฐœ์ž๋„ ๊ณต๊ฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ’€์–ด๋‚ด์ž -> - -| ํŠน์ง• | ์˜ˆ์‹œ | -| --- | --- | -| ๐Ÿค” ๋‚ด ์–ธ์–ด๋กœ ์„ค๋ช…ํ•œ ๊ฐœ๋… | Stub๊ณผ Mock์˜ ์ฐจ์ด๋ฅผ ์ด๋ฒˆ ์ฃผ๋ฌธ ํ…Œ์ŠคํŠธ์—์„œ ์ฒ˜์Œ ์‹ค๊ฐํ–ˆ๋‹ค | -| ๐Ÿ’ญ ํŒ๋‹จ ํ๋ฆ„์ด ๋“œ๋Ÿฌ๋‚˜๋Š” ๊ธ€ | ์ฒ˜์Œ์—” ๋„๋ฉ”์ธ์„ ๋‚˜๋ˆ„์ง€ ์•Š์•˜๋Š”๋ฐ, ํ…Œ์ŠคํŠธ๊ฐ€ ์–ด๋ ค์›Œ์ง€๋ฉฐ ๋ถ„๋ฆฌํ–ˆ๋‹ค | -| ๐Ÿ“ ์ •๋ณด ๋‚˜์—ด๋ณด๋‹ค ์ธ์‚ฌ์ดํŠธ ์ค‘์‹ฌ | ํ…Œ์ŠคํŠธ๋Š” ์ž‘์„ฑํ–ˆ์ง€๋งŒ, ๊ตฌ์กฐ๋Š” ๋งŒ์กฑ์Šค๋Ÿฝ์ง€ ์•Š๋‹ค. ๋‹ค์Œ์—”โ€ฆ | - -### โŒ ํ”ผํ•ด์•ผ ํ•  ์Šคํƒ€์ผ - -| ์˜ˆ์‹œ | ์ด์œ  | -| --- | --- | -| ๋งŽ์ด ๋ถ€์กฑํ–ˆ๊ณ , ๋ฐ˜์„ฑํ•ฉ๋‹ˆ๋‹คโ€ฆ | ํšŒ๊ณ ๊ฐ€ ์•„๋‹ˆ๋ผ ์ผ๊ธฐ์ฒ˜๋Ÿผ ๋ณด์ž…๋‹ˆ๋‹ค | -| Stub์€ ์‘๋‹ต์„ ์ง€์ •ํ•˜๊ณ โ€ฆ | ๋‚ด ์ƒ๊ฐ์ด ์•„๋‹Œ ์š”์•ฝ๋ฌธ์ฒ˜๋Ÿผ ๋ณด์ž…๋‹ˆ๋‹ค | -| ํ…Œ์ŠคํŠธ๊ฐ€ ์ง„๋ฆฌ๋‹ค | ๋„ˆ๋ฌด ๋‹จ์ •์ ์ด๊ฑฐ๋‚˜ ์˜ค๋งŒํ•ด ๋ณด์ž…๋‹ˆ๋‹ค | - -### ๐ŸŽฏ Feature Suggestions - -- ๋‚ด๊ฐ€ ์ž‘์„ฑํ•œ ํ…Œ์ŠคํŠธ ์ค‘ ๊ฐ€์žฅ ์˜๋ฏธ ์žˆ์—ˆ๋˜ ๊ฒƒ 1๊ฐ€์ง€ -- ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ํ•œ ๋ฆฌํŒฉํ† ๋ง -- Mock, Stub, Fake ์ค‘ ์‹ค์ œ ํ™œ์šฉ ๊ฒฝํ—˜๊ณผ ๋‚˜๋งŒ์˜ ๊ตฌ๋ถ„ ๊ธฐ์ค€ -- TDD ๋ฐฉ์‹์œผ๋กœ ์ ‘๊ทผํ•˜๊ฑฐ๋‚˜ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•ด๋ณด๋ฉฐ ์–ด๋ ค์› ๋˜ ์  \ No newline at end of file From c5754ffe7e7c4d2aeff7ad026181f823d5f55896 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Tue, 18 Nov 2025 01:02:46 +0900 Subject: [PATCH 060/164] =?UTF-8?q?fix=20:=20PR=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 2 -- .../application/order/OrderFacade.java | 6 ++-- .../application/product/ProductFacade.java | 6 ++-- .../java/com/loopers/domain/brand/Brand.java | 2 +- .../loopers/domain/brand/BrandService.java | 1 - .../java/com/loopers/domain/order/Order.java | 1 + .../java/com/loopers/domain/point/Point.java | 1 - .../loopers/domain/point/PointService.java | 4 +-- .../com/loopers/domain/product/Product.java | 5 ++- .../domain/product/ProductDomainService.java | 2 ++ .../domain/product/ProductService.java | 18 +++++++++-- .../com/loopers/domain/user/UserService.java | 2 +- .../brand/BrandJpaRepository.java | 3 -- .../product/ProductRepositoryImpl.java | 8 +++-- .../user/UserRepositoryImpl.java | 3 +- .../interfaces/api/point/PointV1ApiSpec.java | 2 +- .../com/loopers/domain/user/UserTest.java | 6 ++-- docs/2round/03-class-diagram.md | 6 ---- docs/3round/3round.md | 32 +++++++++---------- 19 files changed, 59 insertions(+), 51 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 5d885672e..d9dd33205 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -3,7 +3,6 @@ import com.loopers.domain.like.LikeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; /** * packageName : com.loopers.application.like @@ -18,7 +17,6 @@ */ @Component @RequiredArgsConstructor -@Transactional public class LikeFacade { private final LikeService likeService; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 9e06282b4..2fba4b4aa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -4,7 +4,6 @@ import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; import com.loopers.domain.order.OrderStatus; -import com.loopers.domain.point.Point; import com.loopers.domain.point.PointService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; @@ -40,7 +39,7 @@ public class OrderFacade { public OrderInfo createOrder(CreateOrderCommand command) { if (command == null || command.items() == null || command.items().isEmpty()) { - throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ ์ •๋ณด๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"); + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์ •๋ณด๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"); } Order order = Order.create(command.userId()); @@ -71,8 +70,7 @@ public OrderInfo createOrder(CreateOrderCommand command) { order.updateTotalAmount(totalAmount); - Point point = pointService.findPointByUserId(command.userId()); - point.use(totalAmount); + pointService.usePoint(command.userId(), totalAmount); //์ €์žฅ Order saved = orderService.createOrder(order); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 7b83360e7..e6a25de23 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -31,10 +31,10 @@ public class ProductFacade { private final LikeService likeService; private final ProductDomainService productDomainService; - public Page getProducts(Pageable pageable) { - return productService.getProducts(pageable) + public Page getProducts(String sort, Pageable pageable) { + return productService.getProducts(sort ,pageable) .map(product -> { - Brand brand = brandService.getBrand(product.getId()); + Brand brand = brandService.getBrand(product.getBrandId()); long likeCount = likeService.countByProductId(product.getId()); return ProductInfo.of(product, brand, likeCount); }); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index bacd46b25..d334ccebf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -40,7 +40,7 @@ public static Brand create(String name) { private String requireValidName(String name) { if (name == null || name.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช… ๋น„์–ด ์žˆ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } return name.trim(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 6aa724710..e0f58c77b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -23,7 +23,6 @@ public class BrandService { private final BrandRepository brandRepository; - @Transactional public void save(Brand brand) { brandRepository.save(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 9d7b9d3f2..84f299c6b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -58,6 +58,7 @@ public static Order create(String userId) { } public void addOrderItem(OrderItem orderItem) { + orderItem.setOrder(this); this.orderItems.add(orderItem); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index ea0c18c9d..bc28a902a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -42,7 +42,6 @@ public void charge(Long chargeAmount) { throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } this.balance += chargeAmount; - new Point(this.userId, this.balance); } public void use(Long useAmount) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 1a6293f91..9c9570615 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -30,11 +30,11 @@ public Point usePoint(String userId, Long useAmount) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); if (useAmount == null || useAmount <= 0) { - throw new CoreException(ErrorType.NOT_FOUND, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } if (point.getBalance() < useAmount) { - throw new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); } point.use(useAmount); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index cade20580..29968402f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -83,7 +83,7 @@ private Long requireValidPrice(Long price) { return price; } - public Long requireValidLikeCount(Long likeCount) { + private Long requireValidLikeCount(Long likeCount) { if (likeCount == null || likeCount < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } @@ -91,6 +91,9 @@ public Long requireValidLikeCount(Long likeCount) { } private Long requireValidStock(Long stock) { + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } if (stock < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” 0 ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java index f86edfddd..166aff66b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -7,6 +7,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; /** * packageName : com.loopers.domain.product @@ -27,6 +28,7 @@ public class ProductDomainService { private final BrandRepository brandRepository; private final LikeRepository likeRepository; + @Transactional(readOnly = true) public ProductDetail getProductDetail(Long id) { Product product = productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index a9c03fc80..067f194ae 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -4,7 +4,9 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -27,8 +29,20 @@ public class ProductService { private final ProductRepository productRepository; @Transactional(readOnly = true) - public Page getProducts(Pageable pageable) { - return productRepository.findAll(pageable); + public Page getProducts(String sort, Pageable pageable) { + Sort sortOption = switch (sort) { + case "price_asc" -> Sort.by("price").ascending(); + case "likes_desc" -> Sort.by("likeCount").descending(); + default -> Sort.by("createdAt").descending(); // latest + }; + + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + sortOption + ); + + return productRepository.findAll(sortedPageable); } public Product getProduct(Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 57353968a..3cc033076 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -15,7 +15,7 @@ public class UserService { @Transactional public User register(String userId, String email, String birth, String gender) { userRepository.findByUserId(userId).ifPresent(user -> { - throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์žID ์ž…๋‹ˆ๋‹ค."); + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); }); User user = new User(userId, email, birth, gender); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 111990a22..759f3caf1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -3,8 +3,6 @@ import com.loopers.domain.brand.Brand; import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - /** * packageName : com.loopers.infrastructure.brand * fileName : BrandJpaRepository @@ -17,5 +15,4 @@ * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ */ public interface BrandJpaRepository extends JpaRepository { - Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index ba0feb19c..dbad0d9d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -2,6 +2,8 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -38,13 +40,15 @@ public Optional findById(Long id) { @Override public void incrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId).get(); + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); product.increaseLikeCount(); } @Override public void decrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId).get(); + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); product.decreaseLikeCount(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 25f05bc6e..8fb6f7bdf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -20,8 +20,7 @@ public Optional findByUserId(String userId) { @Override public User save(User user) { - userJpaRepository.save(user); - return user; + return userJpaRepository.save(user); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java index faa21f303..6f0458399 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -22,7 +22,7 @@ ApiResponse getPoint( description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•œ๋‹ค." ) ApiResponse chargePoint( - @Schema(name = "ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ", description = "์กฐํšŒํ•  ํšŒ์› ID") + @Schema(name = "ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ", description = "์ถฉ์ „ํ•  ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์š”์ฒญ") PointV1Dto.ChargePointRequest request ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index a8f8948ca..7d74fdfe2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -42,12 +42,12 @@ void throwsException_whenInvalidEmailFormat() { void throwsException_whenInvalidBirthFormat() { // given String userId = "yh45g"; - String invalidEmail = "invalid-email-format"; // '@' ์—†์Œ - String birth = "1994-12-05"; + String email = "valid@loopers.com"; + String invalidBirth = "19941205"; // ํ˜•์‹ ์˜ค๋ฅ˜: ํ•˜์ดํ”ˆ ์—†์Œ String gender = "MALE"; // when & then - assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); + assertThrows(CoreException.class, () -> new User(userId, email, invalidBirth, gender)); } } } diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md index 45421dc8b..8d39cfd0a 100644 --- a/docs/2round/03-class-diagram.md +++ b/docs/2round/03-class-diagram.md @@ -32,11 +32,6 @@ class Product { Long stock } -class Stock { - Long productId - int quantity -} - class Like { Long id String userId @@ -73,7 +68,6 @@ class Payment { %% ๊ด€๊ณ„ ์„ค์ • User --> Point Brand --> Product -Product --> Stock Product --> Like User --> Like User --> Order diff --git a/docs/3round/3round.md b/docs/3round/3round.md index 61df49f68..b9f333cca 100644 --- a/docs/3round/3round.md +++ b/docs/3round/3round.md @@ -24,7 +24,7 @@ ## โœ… Checklist - [x] ์ƒํ’ˆ ์ •๋ณด ๊ฐ์ฒด๋Š” ๋ธŒ๋žœ๋“œ ์ •๋ณด, ์ข‹์•„์š” ์ˆ˜๋ฅผ ํฌํ•จํ•œ๋‹ค. -- [ ] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค +- [x] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค - [x] ์ƒํ’ˆ์€ ์žฌ๊ณ ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๊ณ , ์ฃผ๋ฌธ ์‹œ ์ฐจ๊ฐํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค - [x] ์žฌ๊ณ ๋Š” ๊ฐ์†Œ๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ์Œ์ˆ˜ ๋ฐฉ์ง€๋Š” ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ๋œ๋‹ค @@ -33,28 +33,28 @@ - [x] ์ข‹์•„์š”๋Š” ์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค - [x] ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๊ฐ€ ๊ตฌํ˜„๋˜์—ˆ๋‹ค - [x] ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์กฐํšŒ์—์„œ ํ•จ๊ป˜ ์ œ๊ณต๋œ๋‹ค -- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ/์ค‘๋ณต ๋ฐฉ์ง€ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค +- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ/์ค‘๋ณต ๋ฐฉ์ง€ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค ### ๐Ÿ›’ Order ๋„๋ฉ”์ธ -- [ ] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค -- [ ] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ, ์œ ์ € ํฌ์ธํŠธ ์ฐจ๊ฐ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค -- [ ] ์žฌ๊ณ  ๋ถ€์กฑ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋“ฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค -- [ ] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค +- [x] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค +- [x] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ, ์œ ์ € ํฌ์ธํŠธ ์ฐจ๊ฐ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค +- [x] ์žฌ๊ณ  ๋ถ€์กฑ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋“ฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค +- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค ### ๐Ÿงฉ ๋„๋ฉ”์ธ ์„œ๋น„์Šค -- [ ] ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ๋กœ์ง์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค -- [ ] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ ๋„๋ฉ”์ธ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค -- [ ] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค -- [ ] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค +- [x] ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ๋กœ์ง์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค +- [x] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ ๋„๋ฉ”์ธ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค +- [x] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค +- [x] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค ### **๐Ÿงฑ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ & ์„ค๊ณ„** -- [ ] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค +- [x] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค - Application โ†’ **Domain** โ† Infrastructure -- [ ] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค -- [ ] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค -- [ ] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค -- [ ] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) -- [ ] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค \ No newline at end of file +- [x] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค +- [x] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค +- [x] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค +- [x] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) +- [x] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค \ No newline at end of file From be18c88a093c7086d9683b346c48376d5b6f687f Mon Sep 17 00:00:00 2001 From: BOB <56067193+adminhelper@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:55:58 +0900 Subject: [PATCH 061/164] =?UTF-8?q?Revert=20"[volume-3]=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EB=A7=81=20=EB=B0=8F=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 --- .../application/example/ExampleFacade.java | 17 ++ .../application/example/ExampleInfo.java | 13 ++ .../loopers/application/like/LikeFacade.java | 32 ---- .../application/order/CreateOrderCommand.java | 19 -- .../application/order/OrderFacade.java | 81 --------- .../loopers/application/order/OrderInfo.java | 42 ----- .../application/order/OrderItemCommand.java | 17 -- .../application/order/OrderItemInfo.java | 32 ---- .../application/point/PointFacade.java | 28 --- .../loopers/application/point/PointInfo.java | 13 -- .../product/ProductDetailInfo.java | 32 ---- .../application/product/ProductFacade.java | 47 ----- .../application/product/ProductInfo.java | 33 ---- .../loopers/application/user/UserFacade.java | 27 --- .../loopers/application/user/UserInfo.java | 14 -- .../java/com/loopers/domain/brand/Brand.java | 47 ----- .../loopers/domain/brand/BrandRepository.java | 20 --- .../loopers/domain/brand/BrandService.java | 35 ---- .../loopers/domain/example/ExampleModel.java | 44 +++++ .../domain/example/ExampleRepository.java | 7 + .../domain/example/ExampleService.java | 20 +++ .../java/com/loopers/domain/like/Like.java | 63 ------- .../loopers/domain/like/LikeRepository.java | 25 --- .../com/loopers/domain/like/LikeService.java | 49 ----- .../java/com/loopers/domain/order/Order.java | 86 --------- .../com/loopers/domain/order/OrderItem.java | 91 ---------- .../loopers/domain/order/OrderRepository.java | 21 --- .../loopers/domain/order/OrderService.java | 28 --- .../com/loopers/domain/order/OrderStatus.java | 42 ----- .../java/com/loopers/domain/point/Point.java | 56 ------ .../loopers/domain/point/PointRepository.java | 10 -- .../loopers/domain/point/PointService.java | 43 ----- .../com/loopers/domain/product/Product.java | 120 ------------- .../loopers/domain/product/ProductDetail.java | 45 ----- .../domain/product/ProductDomainService.java | 41 ----- .../domain/product/ProductRepository.java | 29 --- .../domain/product/ProductService.java | 53 ------ .../java/com/loopers/domain/user/User.java | 82 --------- .../loopers/domain/user/UserRepository.java | 10 -- .../com/loopers/domain/user/UserService.java | 30 ---- .../brand/BrandJpaRepository.java | 18 -- .../brand/BrandRepositoryImpl.java | 36 ---- .../example/ExampleJpaRepository.java | 6 + .../example/ExampleRepositoryImpl.java | 19 ++ .../like/LikeJpaRepository.java | 23 --- .../like/LikeRepositoryImpl.java | 46 ----- .../order/OrderJpaRepository.java | 18 -- .../order/OrderRepositoryImpl.java | 36 ---- .../point/PointJpaRepository.java | 11 -- .../point/PointRepositoryImpl.java | 25 --- .../product/ProductJpaRepository.java | 19 -- .../product/ProductRepositoryImpl.java | 59 ------ .../user/UserJpaRepository.java | 11 -- .../user/UserRepositoryImpl.java | 26 --- .../api/example/ExampleV1ApiSpec.java | 19 ++ .../api/example/ExampleV1Controller.java | 28 +++ .../interfaces/api/example/ExampleV1Dto.java | 15 ++ .../interfaces/api/point/PointV1ApiSpec.java | 28 --- .../api/point/PointV1Controller.java | 31 ---- .../interfaces/api/point/PointV1Dto.java | 18 -- .../interfaces/api/user/UserV1ApiSpec.java | 28 --- .../interfaces/api/user/UserV1Controller.java | 31 ---- .../interfaces/api/user/UserV1Dto.java | 24 --- .../com/loopers/domain/brand/BrandTest.java | 42 ----- .../domain/example/ExampleModelTest.java | 65 +++++++ .../ExampleServiceIntegrationTest.java | 72 ++++++++ .../like/LikeServiceIntegrationTest.java | 155 ---------------- .../com/loopers/domain/like/LikeTest.java | 91 ---------- .../order/OrderServiceIntegrationTest.java | 170 ------------------ .../com/loopers/domain/order/OrderTest.java | 122 ------------- .../point/PointServiceIntegrationTest.java | 108 ----------- .../com/loopers/domain/point/PointTest.java | 117 ------------ .../ProductServiceIntegrationTest.java | 43 ----- .../loopers/domain/product/ProductTest.java | 95 ---------- .../user/UserServiceIntegrationTest.java | 112 ------------ .../com/loopers/domain/user/UserTest.java | 53 ------ .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ++++++++++++ .../api/point/PointV1ControllerTest.java | 156 ---------------- .../api/user/UserV1ControllerTest.java | 148 --------------- docs/1round/1round.md | 67 ------- docs/2round/01-requirements.md | 104 ----------- docs/2round/02-sequence-diagrams.md | 164 ----------------- docs/2round/03-class-diagram.md | 78 -------- docs/2round/04-erd.md | 74 -------- docs/2round/2round.md | 37 ---- docs/3round/3round.md | 60 ------- 86 files changed, 439 insertions(+), 3927 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java delete mode 100644 docs/1round/1round.md delete mode 100644 docs/2round/01-requirements.md delete mode 100644 docs/2round/02-sequence-diagrams.md delete mode 100644 docs/2round/03-class-diagram.md delete mode 100644 docs/2round/04-erd.md delete mode 100644 docs/2round/2round.md delete mode 100644 docs/3round/3round.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java new file mode 100644 index 000000000..552a9ad62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java @@ -0,0 +1,17 @@ +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ExampleFacade { + private final ExampleService exampleService; + + public ExampleInfo getExample(Long id) { + ExampleModel example = exampleService.getExample(id); + return ExampleInfo.from(example); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java new file mode 100644 index 000000000..877aba96c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; + +public record ExampleInfo(Long id, String name, String description) { + public static ExampleInfo from(ExampleModel model) { + return new ExampleInfo( + model.getId(), + model.getName(), + model.getDescription() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java deleted file mode 100644 index d9dd33205..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.like.LikeService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * packageName : com.loopers.application.like - * fileName : LikeFacade - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class LikeFacade { - - private final LikeService likeService; - - public void createLike(String userId, Long productId) { - likeService.like(userId, productId); - } - - public void deleteLike(String userId, Long productId) { - likeService.unlike(userId, productId); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java deleted file mode 100644 index 683e39cdd..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.application.order; - -import java.util.List; - -/** - * packageName : com.loopers.application.order - * fileName : CreateOrderCommand - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record CreateOrderCommand( - String userId, - List items -) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java deleted file mode 100644 index 2fba4b4aa..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.OrderStatus; -import com.loopers.domain.point.PointService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.application.order - * fileName : OrderFacade - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Slf4j -@Component -@RequiredArgsConstructor -public class OrderFacade { - - private final OrderService orderService; - private final ProductService productService; - private final PointService pointService; - - @Transactional - public OrderInfo createOrder(CreateOrderCommand command) { - - if (command == null || command.items() == null || command.items().isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์ •๋ณด๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"); - } - - Order order = Order.create(command.userId()); - - for (OrderItemCommand itemCommand : command.items()) { - - //์ƒํ’ˆ๊ฐ€์ ธ์˜ค๊ณ  - Product product = productService.getProduct(itemCommand.productId()); - - // ์žฌ๊ณ ๊ฐ์†Œ - product.decreaseStock(itemCommand.quantity()); - - // OrderItem์ƒ์„ฑ - OrderItem orderItem = OrderItem.create( - product.getId(), - product.getName(), - itemCommand.quantity(), - product.getPrice()); - - order.addOrderItem(orderItem); - orderItem.setOrder(order); - } - - //์ด ๊ฐ€๊ฒฉ๊ตฌํ•˜๊ณ  - long totalAmount = order.getOrderItems().stream() - .mapToLong(OrderItem::getAmount) - .sum(); - - order.updateTotalAmount(totalAmount); - - pointService.usePoint(command.userId(), totalAmount); - - //์ €์žฅ - Order saved = orderService.createOrder(order); - saved.updateStatus(OrderStatus.COMPLETE); - - return OrderInfo.from(saved); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java deleted file mode 100644 index 70028c27c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderStatus; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * packageName : com.loopers.application.order - * fileName : OrderInfo - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record OrderInfo( - Long orderId, - String userId, - Long totalAmount, - OrderStatus status, - LocalDateTime createdAt, - List items -) { - public static OrderInfo from(Order order) { - List itemInfos = order.getOrderItems().stream() - .map(OrderItemInfo::from) - .toList(); - - return new OrderInfo( - order.getId(), - order.getUserId(), - order.getTotalAmount(), - order.getStatus(), - order.getCreatedAt(), - itemInfos - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java deleted file mode 100644 index 1ac46862f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.order; - -/** - * packageName : com.loopers.application.order - * fileName : OrderItemCommand - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record OrderItemCommand( - Long productId, - Long quantity -) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java deleted file mode 100644 index b3f2359c6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.OrderItem; - -/** - * packageName : com.loopers.application.order - * fileName : OrderInfo - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record OrderItemInfo( - Long productId, - String productName, - Long quantity, - Long price, - Long amount -) { - public static OrderItemInfo from(OrderItem item) { - return new OrderItemInfo( - item.getProductId(), - item.getProductName(), - item.getQuantity(), - item.getPrice(), - item.getAmount() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java deleted file mode 100644 index 009be1cec..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.application.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointService; -import com.loopers.interfaces.api.point.PointV1Dto; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class PointFacade { - private final PointService pointService; - - public PointInfo getPoint(String userId) { - Point point = pointService.findPointByUserId(userId); - - if (point == null) { - throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - return PointInfo.from(point); - } - - public PointInfo chargePoint(PointV1Dto.ChargePointRequest request) { - return PointInfo.from(pointService.chargePoint(request.userId(), request.chargeAmount())); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java deleted file mode 100644 index 65497297b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.point; - -import com.loopers.domain.point.Point; - -public record PointInfo(String userId, Long amount) { - public static PointInfo from(Point info) { - return new PointInfo( - info.getUserId(), - info.getBalance() - ); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java deleted file mode 100644 index 2a9ecee27..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.product.ProductDetail; - -/** - * packageName : com.loopers.application.product - * fileName : ProductDetail - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record ProductDetailInfo( - Long id, - String name, - String brandName, - Long price, - Long likeCount -) { - public static ProductDetailInfo from(ProductDetail productDetail) { - return new ProductDetailInfo( - productDetail.getId(), - productDetail.getName(), - productDetail.getBrandName(), - productDetail.getPrice(), - productDetail.getLikeCount() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java deleted file mode 100644 index e6a25de23..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; -import com.loopers.domain.product.ProductDetail; -import com.loopers.domain.product.ProductDomainService; -import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -/** - * packageName : com.loopers.application.product - * fileName : ProdcutFacade - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class ProductFacade { - - private final ProductService productService; - private final BrandService brandService; - private final LikeService likeService; - private final ProductDomainService productDomainService; - - public Page getProducts(String sort, Pageable pageable) { - return productService.getProducts(sort ,pageable) - .map(product -> { - Brand brand = brandService.getBrand(product.getBrandId()); - long likeCount = likeService.countByProductId(product.getId()); - return ProductInfo.of(product, brand, likeCount); - }); - } - - public ProductDetailInfo getProduct(Long id) { - ProductDetail productDetail = productDomainService.getProductDetail(id); - return ProductDetailInfo.from(productDetail); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java deleted file mode 100644 index 8bcd93dd8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; - -/** - * packageName : com.loopers.application.product - * fileName : ProductInfo - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record ProductInfo( - Long id, - String name, - String brandName, - Long price, - Long likeCount -) { - public static ProductInfo of(Product product, Brand brand, Long likeCount) { - return new ProductInfo( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice(), - likeCount - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java deleted file mode 100644 index f42bd5206..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class UserFacade { - private final UserService userService; - - public UserInfo register(String userId, String email, String birth, String gender) { - User user = userService.register(userId, email, birth, gender); - return UserInfo.from(user); - } - - public UserInfo getUser(String userId) { - User user = userService.findUserByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - return UserInfo.from(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java deleted file mode 100644 index 08f5cea43..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; - -public record UserInfo(String userId, String email, String birth, String gender) { - public static UserInfo from(User user) { - return new UserInfo( - user.getUserId(), - user.getEmail(), - user.getBirth(), - user.getGender() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java deleted file mode 100644 index d334ccebf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -/** - * packageName : com.loopers.domain.brand - * fileName : Brand - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Entity -@Table(name = "brand") -@Getter -public class Brand { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String name; - - protected Brand() {} - - private Brand(String name) { - this.name = requireValidName(name); - } - - public static Brand create(String name) { - return new Brand(name); - } - - - private String requireValidName(String name) { - if (name == null || name.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return name.trim(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java deleted file mode 100644 index c558b23fc..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.brand; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.brand - * fileName : BrandRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface BrandRepository { - Optional findById(Long id); - - void save(Brand brand); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java deleted file mode 100644 index e0f58c77b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.brand - * fileName : BrandService - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class BrandService { - - private final BrandRepository brandRepository; - - public void save(Brand brand) { - brandRepository.save(brand); - } - - @Transactional(readOnly = true) - public Brand getBrand(Long id) { - return brandRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java new file mode 100644 index 000000000..c588c4a8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -0,0 +1,44 @@ +package com.loopers.domain.example; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "example") +public class ExampleModel extends BaseEntity { + + private String name; + private String description; + + protected ExampleModel() {} + + public ExampleModel(String name, String description) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (description == null || description.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public void update(String newDescription) { + if (newDescription == null || newDescription.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + this.description = newDescription; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java new file mode 100644 index 000000000..3625e5662 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.example; + +import java.util.Optional; + +public interface ExampleRepository { + Optional find(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java new file mode 100644 index 000000000..c0e8431e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java @@ -0,0 +1,20 @@ +package com.loopers.domain.example; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ExampleService { + + private final ExampleRepository exampleRepository; + + @Transactional(readOnly = true) + public ExampleModel getExample(Long id) { + return exampleRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] ์˜ˆ์‹œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java deleted file mode 100644 index 4430b496a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -import java.time.LocalDateTime; - -/** - * packageName : com.loopers.domain.like - * fileName : Like - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Entity -@Table(name = "product_like") -@Getter -public class Like { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "ref_user_id", nullable = false) - private String userId; - - @Column(name = "ref_product_id", nullable = false) - private Long productId; - - @Column(nullable = false) - private LocalDateTime createdAt; - - protected Like() {} - - private Like(String userId, Long productId) { - this.userId = requireValidUserId(userId); - this.productId = requireValidProductId(productId); - this.createdAt = LocalDateTime.now(); - } - - public static Like create(String userId, Long productId) { - return new Like(userId, productId); - } - - private String requireValidUserId(String userId) { - if (userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return userId; - } - - private Long requireValidProductId(Long productId) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return productId; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java deleted file mode 100644 index 945b10235..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.domain.like; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.like - * fileName : LikeRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface LikeRepository { - - Optional findByUserIdAndProductId(String userId, Long productId); - - void save(Like like); - - void delete(Like like); - - long countByProductId(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java deleted file mode 100644 index 41ae90b6a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.domain.product.ProductRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.application.like - * fileName : LikeService - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class LikeService { - - private final LikeRepository likeRepository; - private final ProductRepository productRepository; - - @Transactional - public void like(String userId, Long productId) { - if (likeRepository.findByUserIdAndProductId(userId, productId).isPresent()) return; - - Like like = Like.create(userId, productId); - likeRepository.save(like); - productRepository.incrementLikeCount(productId); - } - - @Transactional - public void unlike(String userId, Long productId) { - likeRepository.findByUserIdAndProductId(userId, productId) - .ifPresent(like -> { - likeRepository.delete(like); - productRepository.decrementLikeCount(productId); - }); - } - - @Transactional(readOnly = true) - public long countByProductId(Long productId) { - return likeRepository.countByProductId(productId); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java deleted file mode 100644 index 84f299c6b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * packageName : com.loopers.domain.order - * fileName : Order - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Entity -@Table(name = "orders") -@Getter -public class Order { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "ref_user_id", nullable = false) - private String userId; - - @Column(nullable = false) - private Long totalAmount; - - @Enumerated(EnumType.STRING) - private OrderStatus status; - - @Column(nullable = false) - private LocalDateTime createdAt; - - @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) - private List orderItems = new ArrayList<>(); - - protected Order() {} - - private Order(String userId, OrderStatus status) { - this.userId = requiredValidUserId(userId); - this.totalAmount = 0L; - this.status = requiredValidStatus(status); - this.createdAt = LocalDateTime.now(); - } - - public static Order create(String userId) { - return new Order(userId, OrderStatus.PENDING); - } - - public void addOrderItem(OrderItem orderItem) { - orderItem.setOrder(this); - this.orderItems.add(orderItem); - } - - private OrderStatus requiredValidStatus(OrderStatus status) { - if (status == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ƒํƒœ๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); - } - return status; - } - - private String requiredValidUserId(String userId) { - if (userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); - } - return userId; - } - - public void updateTotalAmount(long totalAmount) { - this.totalAmount = totalAmount; - } - - public void updateStatus(OrderStatus status) { - this.status = status; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java deleted file mode 100644 index dce97a44a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderItem - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Entity -@Table(name = "order_item") -@Getter -public class OrderItem { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Setter - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "order_id", nullable = false) - private Order order; - - @Column(name = "ref_product_id", nullable = false) - private Long productId; - - @Column(name = "ref_product_name", nullable = false) - private String productName; - - @Column(nullable = false) - private Long quantity; - - @Column(nullable = false) - private Long price; - - protected OrderItem() {} - - private OrderItem(Long productId, String productName, Long quantity, Long price) { - this.productId = requiredValidProductId(productId); - this.productName = requiredValidProductName(productName); - this.quantity = requiredQuantity(quantity); - this.price = requiredPrice(price); - } - - public static OrderItem create(Long productId, String productName, Long quantity, Long price) { - return new OrderItem(productId, productName, quantity, price); - } - - public Long getAmount() { - return quantity * price; - } - - private Long requiredValidProductId(Long productId) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return productId; - } - - private String requiredValidProductName(String productName) { - if (productName == null || productName.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return productName; - } - - private Long requiredQuantity(Long quantity) { - if (quantity == null || quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return quantity; - } - - private Long requiredPrice(Long price) { - if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return price; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java deleted file mode 100644 index c80262041..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.domain.order; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderRepository - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface OrderRepository { - - Order save(Order order); - - Optional findById(Long orderId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java deleted file mode 100644 index a66be03d3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.domain.order; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderService - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class OrderService { - - private final OrderRepository orderRepository; - - @Transactional - public Order createOrder(Order order) { - return orderRepository.save(order); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java deleted file mode 100644 index 14ea592ef..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain.order; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderStatus - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public enum OrderStatus { - - COMPLETE("๊ฒฐ์ œ์„ฑ๊ณต"), - CANCEL("๊ฒฐ์ œ์ทจ์†Œ"), - FAIL("๊ฒฐ์ œ์‹คํŒจ"), - PENDING("๊ฒฐ์ œ์ค‘"); - - private final String description; - - OrderStatus(String description) { - this.description = description; - } - - public boolean isCompleted() { - return this == COMPLETE; - } - - public boolean isPending() { - return this == PENDING; - } - - public boolean isCanceled() { - return this == CANCEL; - } - - public String description() { - return description; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java deleted file mode 100644 index bc28a902a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -@Entity -@Table(name = "point") -@Getter -public class Point extends BaseEntity { - - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Id - private Long id; - - private String userId; - - private Long balance; - - protected Point() {} - - private Point(String userId, Long balance) { - this.userId = requireValidUserId(userId); - this.balance = balance; - } - - public static Point create(String userId, Long balance) { - return new Point(userId, balance); - } - - String requireValidUserId(String userId) { - if(userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return userId; - } - - public void charge(Long chargeAmount) { - if (chargeAmount == null || chargeAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - this.balance += chargeAmount; - } - - public void use(Long useAmount) { - if (useAmount == null || useAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (this.balance < useAmount) { - throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - this.balance -= useAmount; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java deleted file mode 100644 index 314022491..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain.point; - -import java.util.Optional; - -public interface PointRepository { - - Optional findByUserId(String userId); - - Point save(Point point); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java deleted file mode 100644 index 9c9570615..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class PointService { - - private final PointRepository pointRepository; - - @Transactional(readOnly = true) - public Point findPointByUserId(String userId) { - return pointRepository.findByUserId(userId).orElse(null); - } - - @Transactional - public Point chargePoint(String userId, Long chargeAmount) { - Point point = pointRepository.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ• ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); - point.charge(chargeAmount); - return pointRepository.save(point); - } - - @Transactional - public Point usePoint(String userId, Long useAmount) { - Point point = pointRepository.findByUserId(userId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - if (useAmount == null || useAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - if (point.getBalance() < useAmount) { - throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - - point.use(useAmount); - return pointRepository.save(point); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java deleted file mode 100644 index 29968402f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -/** - * packageName : com.loopers.domain.product - * fileName : Product - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Entity -@Table(name = "product") -@Getter -public class Product { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "ref_brand_id", nullable = false) - private Long brandId; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private Long price; - - @Column - private Long likeCount; - - @Column(nullable = false) - private Long stock; - - protected Product() {} - - private Product(Long brandId, String name, Long price, Long likeCount, Long stock) { - this.brandId = requireValidBrandId(brandId); - this.name = requireValidName(name); - this.price = requireValidPrice(price); - this.likeCount = requireValidLikeCount(likeCount); - this.stock = requireValidStock(stock); - } - - public static Product create(Long brandId, String name, Long price, Long stock) { - return new Product( - brandId, - name, - price, - 0L, - stock - ); - } - - private Long requireValidBrandId(Long brandId) { - if (brandId == null || brandId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - - return brandId; - } - - private String requireValidName(String name) { - if (name == null || name.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return name; - } - - private Long requireValidPrice(Long price) { - if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return price; - } - - private Long requireValidLikeCount(Long likeCount) { - if (likeCount == null || likeCount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return likeCount; - } - - private Long requireValidStock(Long stock) { - if (stock == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - if (stock < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” 0 ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return stock; - } - - public void increaseLikeCount() { - this.likeCount++; - } - - public void decreaseLikeCount() { - if (this.likeCount > 0) this.likeCount--; - } - - public void decreaseStock(Long quantity) { - if (quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (this.stock - quantity < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - this.stock -= quantity; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java deleted file mode 100644 index 808bff196..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.brand.Brand; -import lombok.Getter; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductDetail - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Getter -public class ProductDetail { - - private Long id; - private String name; - private String brandName; - private Long price; - private Long likeCount; - - protected ProductDetail() {} - - private ProductDetail(Long id, String name, String brandName, Long price, Long likeCount) { - this.id = id; - this.name = name; - this.brandName = brandName; - this.price = price; - this.likeCount = likeCount; - } - - public static ProductDetail of(Product product, Brand brand, Long likeCount) { - return new ProductDetail( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice(), - likeCount - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java deleted file mode 100644 index 166aff66b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.like.LikeRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductDetailService - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class ProductDomainService { - - private final ProductRepository productRepository; - private final BrandRepository brandRepository; - private final LikeRepository likeRepository; - - @Transactional(readOnly = true) - public ProductDetail getProductDetail(Long id) { - Product product = productRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - Brand brand = brandRepository.findById(product.getBrandId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); - long likeCount = likeRepository.countByProductId(id); - - return ProductDetail.of(product, brand, likeCount); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java deleted file mode 100644 index dadda62a0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.loopers.domain.product; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductRepositroy - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface ProductRepository { - Page findAll(Pageable pageable); - - Optional findById(Long id); - - void incrementLikeCount(Long productId); - - void decrementLikeCount(Long productId); - - Product save(Product product); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java deleted file mode 100644 index 067f194ae..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductService - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Component -@RequiredArgsConstructor -public class ProductService { - - private final ProductRepository productRepository; - - @Transactional(readOnly = true) - public Page getProducts(String sort, Pageable pageable) { - Sort sortOption = switch (sort) { - case "price_asc" -> Sort.by("price").ascending(); - case "likes_desc" -> Sort.by("likeCount").descending(); - default -> Sort.by("createdAt").descending(); // latest - }; - - Pageable sortedPageable = PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - sortOption - ); - - return productRepository.findAll(sortedPageable); - } - - public Product getProduct(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค")); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java deleted file mode 100644 index 287b84cf8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; - -import java.util.regex.Pattern; - -@Entity -@Table(name = "user") -@Getter -public class User extends BaseEntity { - - private static final Pattern USERID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); - private static final Pattern BIRTH_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); - - @Column(unique = true, nullable = false) - private String userId; - - @Column(nullable = false) - private String email; - - @Column(nullable = false) - private String birth; - - @Column(nullable = false) - private String gender; - - protected User() {} - - public User(String userId, String email, String birth, String gender) { - this.userId = requireValidUserId(userId); - this.email = requireValidEmail(email); - this.birth = requireValidBirthDate(birth); - this.gender = requireValidGender(gender); - } - - String requireValidUserId(String userId) { - if(userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if (!USERID_PATTERN.matcher(userId).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return userId; - } - - String requireValidEmail(String email) { - if(email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if(!EMAIL_PATTERN.matcher(email).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์— ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ex)xx@yy.zz"); - } - return email; - } - - String requireValidBirthDate(String birth) { - if (birth == null || birth.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if(!BIRTH_PATTERN.matcher(birth).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - return birth; - } - - String requireValidGender(String gender) { - if(gender == null || gender.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ฑ๋ณ„์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); - } - return gender; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java deleted file mode 100644 index f4b26266e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain.user; - -import java.util.Optional; - -public interface UserRepository { - - Optional findByUserId(String userId); - - User save(User user); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java deleted file mode 100644 index 3cc033076..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; - - @Transactional - public User register(String userId, String email, String birth, String gender) { - userRepository.findByUserId(userId).ifPresent(user -> { - throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); - }); - - User user = new User(userId, email, birth, gender); - return userRepository.save(user); - } - - @Transactional(readOnly = true) - public User findUserByUserId(String userId) { - return userRepository.findByUserId(userId).orElse(null); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java deleted file mode 100644 index 759f3caf1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.infrastructure.brand; - -import com.loopers.domain.brand.Brand; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * packageName : com.loopers.infrastructure.brand - * fileName : BrandJpaRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface BrandJpaRepository extends JpaRepository { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java deleted file mode 100644 index f23e6e5d9..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.loopers.infrastructure.brand; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.brand - * fileName : BrandRepositroyImpl - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@RequiredArgsConstructor -@Component -public class BrandRepositoryImpl implements BrandRepository { - - private final BrandJpaRepository jpaRepository; - - @Override - public Optional findById(Long id) { - return jpaRepository.findById(id); - } - - @Override - public void save(Brand brand) { - jpaRepository.save(brand); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java new file mode 100644 index 000000000..ce6d3ead0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.example; + +import com.loopers.domain.example.ExampleModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java new file mode 100644 index 000000000..37f2272f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ExampleRepositoryImpl implements ExampleRepository { + private final ExampleJpaRepository exampleJpaRepository; + + @Override + public Optional find(Long id) { + return exampleJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java deleted file mode 100644 index 865a30db7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.loopers.infrastructure.like; - -import com.loopers.domain.like.Like; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.like - * fileName : LikeJpaRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface LikeJpaRepository extends JpaRepository { - Optional findByUserIdAndProductId(String userId, Long productId); - - long countByProductId(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java deleted file mode 100644 index e037b6efb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.infrastructure.like; - -import com.loopers.domain.like.Like; -import com.loopers.domain.like.LikeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.like - * fileName : LikeRepositoryImpl - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class LikeRepositoryImpl implements LikeRepository { - - private final LikeJpaRepository likeJpaRepository; - - @Override - public Optional findByUserIdAndProductId(String userId, Long productId) { - return likeJpaRepository.findByUserIdAndProductId(userId, productId); - } - - @Override - public void save(Like like) { - likeJpaRepository.save(like); - } - - @Override - public void delete(Like like) { - likeJpaRepository.delete(like); - } - - @Override - public long countByProductId(Long productId) { - return likeJpaRepository.countByProductId(productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java deleted file mode 100644 index 39cfb136d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.Order; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * packageName : com.loopers.infrastructure.order - * fileName : OrderJpaRepository - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface OrderJpaRepository extends JpaRepository { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java deleted file mode 100644 index f8c7b5b68..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.order - * fileName : OrderRepositroyImpl - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class OrderRepositoryImpl implements OrderRepository { - - private final OrderJpaRepository orderJpaRepository; - - @Override - public Order save(Order order) { - return orderJpaRepository.save(order); - } - - @Override - public Optional findById(Long orderId) { - return orderJpaRepository.findById(orderId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java deleted file mode 100644 index a35a56151..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface PointJpaRepository extends JpaRepository { - - Optional findByUserId(String userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java deleted file mode 100644 index 530191b66..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class PointRepositoryImpl implements PointRepository { - - private final PointJpaRepository pointJpaRepository; - - @Override - public Optional findByUserId(String userId) { - return pointJpaRepository.findByUserId(userId); - } - - @Override - public Point save(Point point) { - return pointJpaRepository.save(point); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java deleted file mode 100644 index 5ceaae067..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.product; - -import com.loopers.domain.product.Product; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * packageName : com.loopers.infrastructure.product - * fileName : ProductJpaRepository - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface ProductJpaRepository extends JpaRepository { - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java deleted file mode 100644 index dbad0d9d5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.loopers.infrastructure.product; - -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.product - * fileName : ProductRepositoryImpl - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class ProductRepositoryImpl implements ProductRepository { - - private final ProductJpaRepository productJpaRepository; - - @Override - public Page findAll(Pageable pageable) { - return productJpaRepository.findAll(pageable); - } - - @Override - public Optional findById(Long id) { - return productJpaRepository.findById(id); - } - - @Override - public void incrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - product.increaseLikeCount(); - } - - @Override - public void decrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - product.decreaseLikeCount(); - } - - @Override - public Product save(Product product) { - return productJpaRepository.save(product); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java deleted file mode 100644 index f80a5bc52..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserJpaRepository extends JpaRepository { - - Optional findByUserId(String userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java deleted file mode 100644 index 8fb6f7bdf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class UserRepositoryImpl implements UserRepository { - - private final UserJpaRepository userJpaRepository; - - @Override - public Optional findByUserId(String userId) { - return userJpaRepository.findByUserId(userId); - } - - @Override - public User save(User user) { - return userJpaRepository.save(user); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java new file mode 100644 index 000000000..219e3101e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Example V1 API", description = "Loopers ์˜ˆ์‹œ API ์ž…๋‹ˆ๋‹ค.") +public interface ExampleV1ApiSpec { + + @Operation( + summary = "์˜ˆ์‹œ ์กฐํšŒ", + description = "ID๋กœ ์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getExample( + @Schema(name = "์˜ˆ์‹œ ID", description = "์กฐํšŒํ•  ์˜ˆ์‹œ์˜ ID") + Long exampleId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java new file mode 100644 index 000000000..917376016 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleFacade; +import com.loopers.application.example.ExampleInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/examples") +public class ExampleV1Controller implements ExampleV1ApiSpec { + + private final ExampleFacade exampleFacade; + + @GetMapping("/{exampleId}") + @Override + public ApiResponse getExample( + @PathVariable(value = "exampleId") Long exampleId + ) { + ExampleInfo info = exampleFacade.getExample(exampleId); + ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java new file mode 100644 index 000000000..4ecf0eea5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleInfo; + +public class ExampleV1Dto { + public record ExampleResponse(Long id, String name, String description) { + public static ExampleResponse from(ExampleInfo info) { + return new ExampleResponse( + info.id(), + info.name(), + info.description() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java deleted file mode 100644 index 6f0458399..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Point V1 API", description = "Point API ์ž…๋‹ˆ๋‹ค.") -public interface PointV1ApiSpec { - - @Operation( - summary = "ํฌ์ธํŠธ ํšŒ์› ์กฐํšŒ", - description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•œ๋‹ค." - ) - ApiResponse getPoint( - @Schema(name = "ํšŒ์› Id", description = "์กฐํšŒํ•  ํšŒ์› ID") - String userId - ); - - @Operation( - summary = "ํฌ์ธํŠธ ์ถฉ์ „", - description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•œ๋‹ค." - ) - ApiResponse chargePoint( - @Schema(name = "ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ", description = "์ถฉ์ „ํ•  ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์š”์ฒญ") - PointV1Dto.ChargePointRequest request - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java deleted file mode 100644 index 866fce9b3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.application.point.PointFacade; -import com.loopers.application.point.PointInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/points") -public class PointV1Controller implements PointV1ApiSpec { - - private final PointFacade pointFacade; - - @Override - @GetMapping - public ApiResponse getPoint(@RequestHeader("X-USER-ID") String userId) { - PointInfo pointInfo = pointFacade.getPoint(userId); - PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); - return ApiResponse.success(response); - } - - @Override - @PatchMapping("/charge") - public ApiResponse chargePoint(@RequestBody PointV1Dto.ChargePointRequest request) { - PointInfo pointInfo = pointFacade.chargePoint(request); - PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java deleted file mode 100644 index b0b3d050e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.application.point.PointInfo; - -public class PointV1Dto { - - public record ChargePointRequest(String userId, Long chargeAmount) { - } - - public record PointResponse(String userId, Long amount) { - public static PointResponse from(PointInfo info) { - return new PointResponse( - info.userId(), - info.amount() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java deleted file mode 100644 index 1bed68e62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Users V1 API", description = "Users API ์ž…๋‹ˆ๋‹ค.") -public interface UserV1ApiSpec { - - @Operation( - summary = "ํšŒ์› ๊ฐ€์ž…", - description = "ํšŒ์› ๊ฐ€์ž…์„ ํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse register( - @Schema(name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", description = "ํšŒ์›๊ฐ€์ž…") - UserV1Dto.RegisterRequest request - ); - - @Operation( - summary = "ํšŒ์› ์กฐํšŒ", - description = "ํ•ด๋‹น ํšŒ์›์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getUser( - @Schema(name = "ํšŒ์› ID", description = "์กฐํšŒํ•  ํšŒ์› ID") - String userId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java deleted file mode 100644 index aed39ae1f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserFacade; -import com.loopers.application.user.UserInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/users") -public class UserV1Controller implements UserV1ApiSpec { - - private final UserFacade userFacade; - - @Override - @PostMapping("/register") - public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { - UserInfo userInfo = userFacade.register(request.userId(), request.mail(), request.birth(), request.gender()); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); - return ApiResponse.success(response); - } - - @Override - @GetMapping("/{userId}") - public ApiResponse getUser(@PathVariable String userId) { - UserInfo userInfo = userFacade.getUser(userId); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java deleted file mode 100644 index 263214848..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserInfo; - -public class UserV1Dto { - public record RegisterRequest( - String userId, - String mail, - String birth, - String gender - ) { - } - - public record UserResponse(String userId, String email, String birth, String gender) { - public static UserResponse from(UserInfo info) { - return new UserResponse( - info.userId(), - info.email(), - info.birth(), - info.gender() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java deleted file mode 100644 index 9541c11f4..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * packageName : com.loopers.domain.brand - * fileName : BrandTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class BrandTest { - - @DisplayName("Brand ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") - @Nested - class CreateBrandTest { - - @Test - @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ฑ ์„ฑ๊ณต") - void createBrandSuccess() { - Brand brand = Brand.create("Nike"); - assertThat(brand.getName()).isEqualTo("Nike"); - } - - @Test - @DisplayName("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ") - void createBrandFail() { - assertThatThrownBy(() -> Brand.create("")) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java new file mode 100644 index 000000000..44ca7576e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -0,0 +1,65 @@ +package com.loopers.domain.example; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ExampleModelTest { + @DisplayName("์˜ˆ์‹œ ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsExampleModel_whenNameAndDescriptionAreProvided() { + // arrange + String name = "์ œ๋ชฉ"; + String description = "์„ค๋ช…"; + + // act + ExampleModel exampleModel = new ExampleModel(name, description); + + // assert + assertAll( + () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) + ); + } + + @DisplayName("์ œ๋ชฉ์ด ๋นˆ์นธ์œผ๋กœ๋งŒ ์ด๋ฃจ์–ด์ ธ ์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenTitleIsBlank() { + // arrange + String name = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ExampleModel(name, "์„ค๋ช…"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenDescriptionIsEmpty() { + // arrange + String description = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ExampleModel("์ œ๋ชฉ", description); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java new file mode 100644 index 000000000..bbd5fdbe1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.example; + +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ExampleServiceIntegrationTest { + @Autowired + private ExampleService exampleService; + + @Autowired + private ExampleJpaRepository exampleJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class Get { + @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange + ExampleModel exampleModel = exampleJpaRepository.save( + new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") + ); + + // act + ExampleModel result = exampleService.getExample(exampleModel.getId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), + () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), + () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidIdIsProvided() { + // arrange + Long invalidId = 999L; // Assuming this ID does not exist + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + exampleService.getExample(invalidId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java deleted file mode 100644 index 0be07a6fb..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -import static org.assertj.core.api.Assertions.*; - -/** - * packageName : com.loopers.domain.like - * fileName : LikeServiceIntegrationTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@SpringBootTest -class LikeServiceIntegrationTest { - - @Autowired - private LikeService likeService; - - @Autowired - private LikeRepository likeRepository; - - @Autowired - private ProductRepository productRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private DatabaseCleanUp cleanUp; - - @AfterEach - void tearDown() { - cleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("์ข‹์•„์š” ๊ธฐ๋Šฅ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - class LikeTests { - - @Test - @DisplayName("์ข‹์•„์š” ์ƒ์„ฑ ์„ฑ๊ณต โ†’ ์ข‹์•„์š” ์ €์žฅ + ์ƒํ’ˆ์˜ likeCount ์ฆ๊ฐ€") - @Transactional - void likeSuccess() { - // given - User user = userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - Product product = productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - // when - likeService.like(user.getUserId(), product.getId()); - - // then - Like saved = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); - assertThat(saved).isNotNull(); - - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(1L); - } - - @Test - @DisplayName("์ค‘๋ณต ์ข‹์•„์š” ์‹œ likeCount ์ฆ๊ฐ€ ์•ˆ ํ•˜๊ณ  ์ €์žฅ๋„ ์•ˆ ๋จ") - @Transactional - void duplicateLike() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - likeService.like("user1", 1L); - - // when - likeService.like("user1", 1L); // ์ค‘๋ณต ํ˜ธ์ถœ - - // then - long likeCount = likeRepository.countByProductId(1L); - assertThat(likeCount).isEqualTo(1L); - - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(1L); // ์ฆ๊ฐ€ X - } - - @Test - @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ์„ฑ๊ณต โ†’ like ์‚ญ์ œ + ์ƒํ’ˆ์˜ likeCount ๊ฐ์†Œ") - @Transactional - void unlikeSuccess() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - likeService.like("user1", 1L); - - // when - likeService.unlike("user1", 1L); - - // then - Like like = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); - assertThat(like).isNull(); - - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(0L); - } - - @Test - @DisplayName("์—†๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ likeCount ๊ฐ์†Œ ์•ˆ ํ•จ") - @Transactional - void unlikeNonExisting() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - Product product = Product.create(1L, "์ƒํ’ˆA", 1000L, 10L); - product.increaseLikeCount(); - product.increaseLikeCount(); - product.increaseLikeCount(); - product.increaseLikeCount(); - product.increaseLikeCount(); - - productRepository.save(product); - // when โ€” ํ˜ธ์ถœ์€ ํ•ด๋„ - likeService.unlike("user1", 1L); - - // then โ€” ๋ณ€ํ™” ์—†์Œ - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(5L); - } - - @Test - @DisplayName("countByProductId ์ •์ƒ ์กฐํšŒ") - @Transactional - void countTest() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - userRepository.save(new User("user2", "u2@mail.com", "1991-01-01", "MALE")); - productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - likeService.like("user1", 1L); - likeService.like("user2", 1L); - - // when - long count = likeService.countByProductId(1L); - - // then - assertThat(count).isEqualTo(2L); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java deleted file mode 100644 index d5b8bd851..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * packageName : com.loopers.domain.like - * fileName : LikeTest - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class LikeTest { - - - @DisplayName("์ •์ƒ์ ์œผ๋กœ Like ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ƒ์„ฑ์ˆ˜ ํ•  ์žˆ๋‹ค") - @Nested - class LikeCreate { - - @DisplayName("Like์ƒ์„ฑ์ž๋ฅผ ์ƒ์„ฑํ•œ๋‹ค") - @Test - void createLike_success() { - // given - String userId = "user-001"; - Long productId = 100L; - - // when - Like like = Like.create(userId, productId); - - // then - assertThat(like.getUserId()).isEqualTo(userId); - assertThat(like.getProductId()).isEqualTo(productId); - assertThat(like.getCreatedAt()).isBeforeOrEqualTo(LocalDateTime.now()); - } - - @Test - @DisplayName("userId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidUserId_null() { - // given - String userId = null; - Long productId = 100L; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - - @Test - @DisplayName("userId๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidUserId_empty() { - // given - String userId = ""; - Long productId = 100L; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - - @Test - @DisplayName("productId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidProductId_null() { - // given - String userId = "user-001"; - Long productId = null; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - - @Test - @DisplayName("productId๊ฐ€ 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidProductId_zeroOrNegative() { - // given - String userId = "user-001"; - Long productId = -1L; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java deleted file mode 100644 index 149e71540..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.application.order.CreateOrderCommand; -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; -import com.loopers.application.order.OrderItemCommand; -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderServiceIntegrationTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@SpringBootTest -public class OrderServiceIntegrationTest { - - @Autowired - private OrderFacade orderFacade; - - @Autowired - private ProductRepository productRepository; - - @Autowired - private PointRepository pointRepository; - - @Autowired - private OrderRepository orderRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") - class OrderCreateSuccess { - - @Test - @Transactional - void createOrder_success() { - - // given - Product p1 = productRepository.save(Product.create(1L, "์•„๋ฉ”๋ฆฌ์นด๋…ธ", 3000L, 100L)); - Product p2 = productRepository.save(Product.create(1L, "๋ผ๋–ผ", 4000L, 200L)); - - pointRepository.save(Point.create("user1", 20000L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of( - new OrderItemCommand(p1.getId(), 2L), // 6000์› - new OrderItemCommand(p2.getId(), 1L) // 4000์› - ) - ); - - // when - OrderInfo info = orderFacade.createOrder(command); - - // then - Order saved = orderRepository.findById(info.orderId()).orElseThrow(); - - assertThat(saved.getStatus()).isEqualTo(OrderStatus.COMPLETE); - assertThat(saved.getTotalAmount()).isEqualTo(10000L); - assertThat(saved.getOrderItems()).hasSize(2); - - // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ - Product updated1 = productRepository.findById(p1.getId()).get(); - Product updated2 = productRepository.findById(p2.getId()).get(); - assertThat(updated1.getStock()).isEqualTo(98); - assertThat(updated2.getStock()).isEqualTo(199); - - // ํฌ์ธํŠธ ๊ฐ์†Œ ํ™•์ธ - Point point = pointRepository.findByUserId("user1").get(); - assertThat(point.getBalance()).isEqualTo(10000L); // 20000 - 10000 - - } - } - - @Nested - @DisplayName("์ฃผ๋ฌธ ์‹คํŒจ ์ผ€์ด์Šค") - class OrderCreateFail { - - @Test - @Transactional - @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") - void insufficientStock_fail() { - Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 1L)); - pointRepository.save(Point.create("user1", 5000L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(item.getId(), 5L)) - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .isInstanceOf(RuntimeException.class); // ๋„ˆ์˜ ๋„๋ฉ”์ธ ์˜ˆ์™ธ ํƒ€์ž… ๋งž์ถฐ๋„ ๋จ - } - - @Test - @Transactional - @DisplayName("ํฌ์ธํŠธ ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") - void insufficientPoint_fail() { - Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); - pointRepository.save(Point.create("user1", 2000L)); // ๋ถ€์กฑ - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(item.getId(), 5L)) // ์ด 5000์› - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .hasMessageContaining("ํฌ์ธํŠธ"); // ๋ฉ”์‹œ์ง€ ๋งž์ถ”๋ฉด ๋” ์ •ํ™•ํ•˜๊ฒŒ ๊ฐ€๋Šฅ - } - - @Test - @Transactional - @DisplayName("์—†๋Š” ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ ์‹คํŒจ") - void noProduct_fail() { - pointRepository.save(Point.create("user1", 10000L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(999L, 1L)) - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .isInstanceOf(RuntimeException.class); - } - - @Test - @Transactional - @DisplayName("์œ ์ € ํฌ์ธํŠธ ์ •๋ณด ์—†์œผ๋ฉด ์‹คํŒจ") - void noUserPoint_fail() { - Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(item.getId(), 1L)) - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .isInstanceOf(RuntimeException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java deleted file mode 100644 index 60ed16ecc..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class OrderTest { - - @Nested - @DisplayName("Order ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") - class CreateOrderTest { - - @Test - @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") - void createOrderSuccess() { - // when - Order order = Order.create("user123"); - - // then - assertThat(order.getUserId()).isEqualTo("user123"); - assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); - assertThat(order.getTotalAmount()).isEqualTo(0L); - assertThat(order.getCreatedAt()).isNotNull(); - assertThat(order.getOrderItems()).isEmpty(); - } - - @Test - @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createOrderFailUserIdNull() { - assertThatThrownBy(() -> Order.create(null)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); - } - - @Test - @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createOrderFailUserIdBlank() { - assertThatThrownBy(() -> Order.create("")) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); - } - } - - @Nested - @DisplayName("Order ์ƒํƒœ ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") - class UpdateStatusTest { - - @Test - @DisplayName("์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") - void updateStatusSuccess() { - // given - Order order = Order.create("user123"); - - // when - order.updateStatus(OrderStatus.COMPLETE); - - // then - assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETE); - } - } - - @Nested - @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") - class UpdateAmountTest { - - @Test - @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") - void updateTotalAmountSuccess() { - // given - Order order = Order.create("user123"); - - // when - order.updateTotalAmount(5000L); - - // then - assertThat(order.getTotalAmount()).isEqualTo(5000L); - } - } - - @Nested - @DisplayName("OrderItem ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ") - class AddOrderItemTest { - - @Test - @DisplayName("OrderItem ์ถ”๊ฐ€ ์„ฑ๊ณต") - void addOrderItemSuccess() { - // given - Order order = Order.create("user123"); - - OrderItem item = OrderItem.create( - 1L, - "์ƒํ’ˆ๋ช…", - 2L, - 1000L - ); - - // when - order.addOrderItem(item); - item.setOrder(order); - - // then - assertThat(order.getOrderItems()).hasSize(1); - assertThat(order.getOrderItems().getFirst().getProductName()).isEqualTo("์ƒํ’ˆ๋ช…"); - assertThat(item.getOrder()).isEqualTo(order); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java deleted file mode 100644 index b623bc9c7..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class PointServiceIntegrationTest { - - @Autowired - private PointRepository pointRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private PointService pointService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class PointUser { - - @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnPointInfo_whenValidIdIsProvided() { - //given - String id = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(Point.create(id, 0L)); - - //when - Point result = pointService.findPointByUserId(id); - - //then - assertThat(result.getUserId()).isEqualTo(id); - assertThat(result.getBalance()).isEqualTo(0L); - } - - @DisplayName("ํšŒ์›์ด ์กด์žฌ ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnNull_whenInvalidUserIdIsProvided() { - //given - String id = "yh45g"; - - //when - Point point = pointService.findPointByUserId(id); - - //then - assertThat(point).isNull(); - } - } - - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class Charge { - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsChargeAmountFailException_whenUserIDIsNotProvided() { - //given - String id = "yh45g"; - - //when - CoreException exception = assertThrows(CoreException.class, () -> pointService.chargePoint(id, 1000L)); - - //then - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - - @Test - @DisplayName("ํšŒ์›์ด ์กด์žฌํ•˜๋ฉด ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") - void chargeSuccess() { - // given - String userId = "user2"; - userRepository.save(new User(userId, "yh45g@loopers.com", "1994-12-05", "MALE")); - pointRepository.save(Point.create(userId, 1000L)); - - // when - Point updated = pointService.chargePoint(userId, 500L); - - // then - assertThat(updated.getBalance()).isEqualTo(1500L); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java deleted file mode 100644 index f33fb2821..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; - -class PointTest { - - @Nested - @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") - class CreatePointTest { - - @Test - @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ์„ฑ๊ณต") - void createPointSuccess() { - // when - Point point = Point.create("user123", 100L); - - // then - assertThat(point.getUserId()).isEqualTo("user123"); - assertThat(point.getBalance()).isEqualTo(100L); - } - - @Test - @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createPointFailUserIdNull() { - assertThatThrownBy(() -> Point.create(null, 100L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - @Test - @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createPointFailUserIdBlank() { - assertThatThrownBy(() -> Point.create("", 100L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - } - - @Nested - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ…Œ์ŠคํŠธ") - class ChargePointTest { - - @Test - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") - void chargeSuccess() { - // given - Point point = Point.create("user123", 100L); - - // when - point.charge(50L); - - // then - assertThat(point.getBalance()).isEqualTo(150L); - } - - @Test - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") - void chargeFailZeroOrNegative() { - Point point = Point.create("user123", 100L); - - assertThatThrownBy(() -> point.charge(0L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์ถฉ์ „"); - - assertThatThrownBy(() -> point.charge(-10L)) - .isInstanceOf(CoreException.class); - } - } - - @Nested - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ํ…Œ์ŠคํŠธ") - class UsePointTest { - - @Test - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์„ฑ๊ณต") - void useSuccess() { - // given - Point point = Point.create("user123", 100L); - - // when - point.use(40L); - - // then - assertThat(point.getBalance()).isEqualTo(60L); - } - - @Test - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") - void useFailZeroOrNegative() { - Point point = Point.create("user123", 100L); - - assertThatThrownBy(() -> point.use(0L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - - assertThatThrownBy(() -> point.use(-10L)) - .isInstanceOf(CoreException.class); - } - - @Test - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - ์ž”์•ก ๋ถ€์กฑ") - void useFailNotEnough() { - Point point = Point.create("user123", 50L); - - assertThatThrownBy(() -> point.use(100L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑ"); - } - } - -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java deleted file mode 100644 index 8ad61a194..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductServiceIntegrationTest - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@SpringBootTest -public class ProductServiceIntegrationTest { - - @Autowired - private ProductService productService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ํ…Œ์ŠคํŠธ") - class ProductListTests { - - Product product; - - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java deleted file mode 100644 index c2c6fdd9b..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductTest - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class ProductTest { - @DisplayName("Product ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ ํ…Œ์ŠคํŠธ") - @Nested - class LikeCountChange { - - @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค.") - @Test - void increaseLikeCount_incrementsLikeCount() { - // given - Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - // when - product.increaseLikeCount(); - - // then - assertEquals(1L, product.getLikeCount()); - } - - @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ๊ฐ์†Œ์‹œํ‚จ๋‹ค. 0 ๋ฏธ๋งŒ์œผ๋กœ๋Š” ๊ฐ์†Œํ•˜์ง€ ์•Š๋Š”๋‹ค.") - @Test - void decreaseLikeCount_decrementsLikeCountButNotBelowZero() { - // given - Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 1L); - - // when - product.decreaseLikeCount(); - - // then - assertEquals(0L, product.getLikeCount()); - - // when decrease again - product.decreaseLikeCount(); - - // then likeCount should not go below 0 - assertEquals(0L, product.getLikeCount()); - } - } - - @DisplayName("Product ์žฌ๊ณ  ์ฐจ๊ฐ ํ…Œ์ŠคํŠธ") - @Nested - class Stock { - - @DisplayName("์žฌ๊ณ ๋ฅผ ์ •์ƒ ์ฐจ๊ฐํ•œ๋‹ค.") - @Test - void decreaseStock_successfullyDecreasesStock() { - // given - Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - // when - product.decreaseStock(3L); - - // then - assertEquals(7, product.getStock()); - } - - @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void decreaseStock_withInvalidQuantity_throwsException() { - Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - assertThrows(CoreException.class, () -> product.decreaseStock(0L)); - assertThrows(CoreException.class, () -> product.decreaseStock(-1L)); - } - - @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ํฐ ์ˆ˜๋Ÿ‰ ์ฐจ๊ฐ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void decreaseStock_withInsufficientStock_throwsException() { - Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - assertThrows(CoreException.class, () -> product.decreaseStock(11L)); - } - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java deleted file mode 100644 index 71091883f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; - -@SpringBootTest -class UserServiceIntegrationTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserService userService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("ํšŒ์› ๊ฐ€์ž… ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class UserRegister { - - @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") - @Test - void save_whenUserRegister() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String brith = "1994-12-05"; - String gender = "Male"; - - UserRepository userRepositorySpy = spy(userRepository); - UserService userServiceSpy = new UserService(userRepositorySpy); - - //when - userServiceSpy.register(userId, email, brith, gender); - - //then - verify(userRepositorySpy).save(any(User.class)); - } - - @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenDuplicateUserId() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String brith = "1994-12-05"; - String gender = "Male"; - - //when - userService.register(userId, email, brith, gender); - - //then - Assertions.assertThrows(CoreException.class, () - -> userService.register(userId, email, brith, gender)); - } - } - - @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class Get { - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsUser_whenValidIdIsProvided() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String brith = "1994-12-05"; - String gender = "Male"; - - //when - userService.register(userId, email, brith, gender); - User user = userService.findUserByUserId(userId); - - //then - assertAll( - () -> assertThat(user.getUserId()).isEqualTo(userId), - () -> assertThat(user.getEmail()).isEqualTo(email), - () -> assertThat(user.getBirth()).isEqualTo(brith), - () -> assertThat(user.getGender()).isEqualTo(gender) - ); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnNull_whenInvalidUserIdIsProvided() { - //given - String userId = "yh45g"; - - //when - User user = userService.findUserByUserId(userId); - - //then - assertThat(user).isNull(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java deleted file mode 100644 index 7d74fdfe2..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -class UserTest { - @DisplayName("User ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") - @Nested - class Create { - @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidUserIdFormat() { - // given - String invalidUserId = "invalid_id_123"; // 10์ž ์ดˆ๊ณผ + ํŠน์ˆ˜๋ฌธ์ž ํฌํ•จ - String email = "valid@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - // when & then - assertThrows(CoreException.class, () -> new User(invalidUserId, email, birth, gender)); - } - - @DisplayName("์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidEmailFormat() { - // given - String userId = "yh45g"; - String invalidEmail = "invalid-email-format"; // '@' ์—†์Œ - String birth = "1994-12-05"; - String gender = "MALE"; - - // when & then - assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidBirthFormat() { - // given - String userId = "yh45g"; - String email = "valid@loopers.com"; - String invalidBirth = "19941205"; // ํ˜•์‹ ์˜ค๋ฅ˜: ํ•˜์ดํ”ˆ ์—†์Œ - String gender = "MALE"; - - // when & then - assertThrows(CoreException.class, () -> new User(userId, email, invalidBirth, gender)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java new file mode 100644 index 000000000..1bb3dba65 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -0,0 +1,114 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.interfaces.api.example.ExampleV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ExampleV1ApiE2ETest { + + private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; + + private final TestRestTemplate testRestTemplate; + private final ExampleJpaRepository exampleJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ExampleV1ApiE2ETest( + TestRestTemplate testRestTemplate, + ExampleJpaRepository exampleJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.exampleJpaRepository = exampleJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/examples/{id}") + @Nested + class Get { + @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange + ExampleModel exampleModel = exampleJpaRepository.save( + new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") + ); + String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), + () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) + ); + } + + @DisplayName("์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID ๋กœ ์š”์ฒญํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenIdIsNotProvided() { + // arrange + String requestUrl = "/api/v1/examples/๋‚˜๋‚˜"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, 404 NOT_FOUND ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsException_whenInvalidIdIsProvided() { + // arrange + Long invalidId = -1L; + String requestUrl = ENDPOINT_GET.apply(invalidId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java deleted file mode 100644 index 7d7a2c18c..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class PointV1ControllerTest { - - private static final String GET_USER_POINT_ENDPOINT = "/api/v1/points"; - private static final String POST_USER_POINT_ENDPOINT = "/api/v1/points/charge"; - - @Autowired - private PointRepository pointRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @Autowired - private TestRestTemplate testRestTemplate; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/points") - @Nested - class UserPoint { - - @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnPoint_whenValidUserIdIsProvided() { - //given - String id = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - Long amount = 1000L; - - userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(Point.create(id, amount)); - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(id), - () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) - ); - } - - @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNull_whenUserIdExists() { - //given - String id = "yh45g"; - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getBody().data()).isNull() - ); - } - } - - @DisplayName("POST /api/v1/points/charge") - @Nested - class Charge { - - @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsTotalPoint_whenChargeUserPoint() { - //given - String id = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(Point.create(id, 0L)); - - PointV1Dto.ChargePointRequest request = new PointV1Dto.ChargePointRequest(id, 1000L); - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(id), - () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidUserIdIsProvided() { - //given - String id = "yh45g"; - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(null, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java deleted file mode 100644 index defe2fcd5..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.domain.user.User; -import com.loopers.infrastructure.user.UserJpaRepository; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class UserV1ControllerTest { - - private static final String USER_REGISTER_ENDPOINT = "/api/v1/users/register"; - private static final Function GET_USER_ENDPOINT = id -> "/api/v1/users/" + id; - - @Autowired - private TestRestTemplate testRestTemplate; - - @Autowired - private UserJpaRepository userJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("POST /api/v1/users") - @Nested - class RegisterUser { - @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void registerUser_whenSuccessResponseUser() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), - () -> assertThat(response.getBody().data().email()).isEqualTo(email), - () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), - () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) - ); - } - @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") - @Test - void throwsBadRequest_whenGenderIsNotProvided() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = null; - - UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - } - - @DisplayName("GET /api/v1/users/{userId}") - @Nested - class GetUserById { - @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void getUserById_whenSuccessResponseUser() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - userJpaRepository.save(new User(userId, email, birth, gender)); - - String requestUrl = GET_USER_ENDPOINT.apply(userId); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), - () -> assertThat(response.getBody().data().email()).isEqualTo(email), - () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), - () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidUserIdIsProvided() { - //given - String userId = "notUserId"; - String requestUrl = GET_USER_ENDPOINT.apply(userId); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/docs/1round/1round.md b/docs/1round/1round.md deleted file mode 100644 index 106d6c809..000000000 --- a/docs/1round/1round.md +++ /dev/null @@ -1,67 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. -> - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [x] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [x] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [x] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [x] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [x] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [x] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [x] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [x] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [x] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [x] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [x] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์ถฉ์ „ - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [X] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [X] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -## โœ… Checklist - -- [X] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ -- [X] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ -- [X] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ \ No newline at end of file diff --git a/docs/2round/01-requirements.md b/docs/2round/01-requirements.md deleted file mode 100644 index 3296c21c6..000000000 --- a/docs/2round/01-requirements.md +++ /dev/null @@ -1,104 +0,0 @@ -# ์œ ์ €-์‹œ๋‚˜๋ฆฌ์˜ค - -## ์ƒํ’ˆ ๋ชฉ๋ก -1. ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋“  ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ๋ณผ์ˆ˜ ์žˆ๋‹ค. -2. ํŒ๋งค์ค‘์ธ ์ƒํ’ˆ์— ๋Œ€ํ•œ ํŒ๋งค๋ช…, ํŒ๋งค๊ธˆ์•ก, ํŒ๋งค๋ธŒ๋žœ๋“œ, ์ด๋ฏธ์ง€, ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. -3. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ณ„๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜ ์žˆ๋‹ค. -4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์ƒํ’ˆ์—๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ์ˆ˜์žˆ๋‹ค. -5. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. -6. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. - --[๊ธฐ๋Šฅ] -1. ์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -2. ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก -4. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) -5. ํŽ˜์ด์ง• - --[์ œ์•ฝ] -1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค -2. ์ƒํ’ˆ์ด ์—†์„๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. - ---- -## ์ƒํ’ˆ ์ƒ์„ธ -1. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŒ๋งค์ค‘ ์ƒํ’ˆ(ํŒ๋งค๋ช…,ํŒ๋งค๊ธˆ์•ก,ํŒ๋งค๋ธŒ๋žœ๋“œ,์ด๋ฏธ์ง€,์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. - -[๊ธฐ๋Šฅ] -1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ๋ฒˆํ˜ธ๋กœ ์กฐํšŒ -2. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก / ์ทจ์†Œ - -[์ œ์•ฝ] -1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค -2. ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. - ---- -## ์ข‹์•„์š” -1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ ์ˆ˜ ์žˆ๋‹ค. -2. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œ ํ•  ์ˆ˜ ์žˆ๋‹ค. -2. ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด ๋ชฉ๋ก์„ ๋ณผ์ˆ˜์žˆ๋‹ค. - -[๊ธฐ๋Šฅ] -1. ์ข‹์•„์š” ๋ˆ„๋ฅธ ์ƒํ’ˆ์—๋Œ€ํ•ด ๋ชฉ๋ก ์กฐํšŒ -2. ์‚ฌ์šฉ๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•œ ๋“ฑ๋ก/์ทจ์†Œ, ๋‹จ ๋“ฑ๋ก/ํ•ด์ œ (๋ฉฑ๋“ฑ์„ฑ) - -[์ œ์•ฝ] -1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค -2. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ์ฒ˜์Œ ๋“ฑ๋ก ํ• ๋•Œ๋Š” 201_Created ์ œ๊ณตํ•œ๋‹ค -3. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ํ•œ๋ฒˆ๋” ๋“ฑ๋ก ํ• ๋•Œ๋Š” 200_OK ์ œ๊ณตํ•œ๋‹ค -4. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก ๋œ ์ƒํƒœ์—์„œ ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค -5. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๊ฐ€ ๋œ ์ƒํƒœ์—์„œ ํ•œ๋ฒˆ๋” ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค ---- -## ๋ธŒ๋žœ๋“œ -1. ์‚ฌ์šฉ์ž๋Š” ๋ชจ๋“  ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. -2. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๋ธŒ๋žœ๋“œ์— ๋Œ€ํ•œ ์ƒํ’ˆ๋งŒ ๋ณผ์ˆ˜์žˆ๋‹ค. -3. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๊ธฐ์ค€์œผ๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ) -4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. - [๊ธฐ๋Šฅ] -1. ๋ชจ๋“  ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ -2. ํŠน์ • ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ -3. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) -4. ํŽ˜์ด์ง• - [์ œ์•ฝ] -1. ๋ธŒ๋žœ๋“œ๊ฐ€ ์—†์„์‹œ 404_NOTFOUND๋ฅผ ์ œ๊ณตํ•œ๋‹ค ---- -## ์ฃผ๋ฌธ -1. ์‚ฌ์šฉ์ž๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก์—์„œ ์›ํ•˜๋Š” ์ƒํ’ˆ์„ ์„ ํƒํ•˜์—ฌ ์ฃผ๋ฌธํ•  ์ˆ˜ ์žˆ๋‹ค. -2. ํ•œ๊ฐœ์˜ ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ๋‹ค. -3. ์‚ฌ์šฉ์ž๊ฐ€ ์ฃผ๋ฌธ ๋‚ด์—ญ์„ ์กฐํšŒํ•ด ์–ด๋–ค ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. -4. ์‚ฌ์šฉ์ž๋Š” ๊ฒฐ์ œ ์ „์ด๋ผ๋ฉด ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•  ์ˆ˜ ์žˆ๋‹ค. -5. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธ ์ƒ์„ธ ํ™”๋ฉด์—์„œ ์ƒํ’ˆ ์ •๋ณด, ์ˆ˜๋Ÿ‰, ๊ฒฐ์ œ ๊ธˆ์•ก, ์ƒํƒœ ๋“ฑ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. - [๊ธฐ๋Šฅ] -1. ์ฃผ๋ฌธ ์ƒ์„ฑ -2. ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ -3. ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ -4. ์ฃผ๋ฌธ ์ทจ์†Œ -5. ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์ƒํƒœ๊ด€๋ฆฌ - [์ œ์•ฝ] -1. ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ ์žฌ๊ณ  ํ™•์ธ ๋ฐ ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ -2. ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ ์‹œ ์ฃผ๋ฌธ ๋ถˆ๊ฐ€ -3. ๋™์ผํ•œ ์ฃผ๋ฌธ ์š”์ฒญ์ด ์ค‘๋ณต์œผ๋กœ ๋“ค์–ด์™€๋„ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ ---- -## ๊ฒฐ์ œ -1. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธํ•œ ์ƒํ’ˆ์— ๋Œ€ํ•ด ํฌ์ธํŠธ๋กœ ๊ฒฐ์ œํ•  ์ˆ˜ ์žˆ๋‹ค. -2. ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ๊ฒฐ์ œ ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค. -3. ๊ฒฐ์ œ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๊ฒฐ์ œ ์‹คํŒจ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ํฌ์ธํŠธ์™€ ์žฌ๊ณ ๋Š” ๋ณต๊ตฌ๋œ๋‹ค. -4. ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„์—๋Š” ์ฃผ๋ฌธ ์ทจ์†Œ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค. - [๊ธฐ๋Šฅ] -1. ๊ฒฐ์ œ์š”์ฒญ -2. ๊ฒฐ์ œ ๊ฒฐ๊ณผ ๋ฐ˜์˜ -3. ๊ฒฐ์ œ ์‹คํŒจ ์ฒ˜๋ฆฌ -4. ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ - [์ œ์•ฝ] -1. ๋™์ผ ์ฃผ๋ฌธ์— ๋Œ€ํ•ด ์ค‘๋ณต ๊ฒฐ์ œ ์š”์ฒญ ์‹œ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ -2. ํฌ์ธํŠธ ์ฐจ๊ฐ์‹คํŒจ ์‹œ ๋ณต๊ตฌ -3. ์™ธ๋ถ€๊ฒฐ์ œ ์‹œ์Šคํ…œ ๊ฒฐ์ œ ์‹œ์Šคํ…œ ์ฒ˜๋ฆฌ ์‹คํŒจ์‹œ ์˜ˆ์™ธ์ฒ˜๋ฆฌ - ----- -## Ubiquitous -| ํ•œ๊ตญ์–ด | ์˜์–ด | -|--------|------| -| ์‚ฌ์šฉ์ž | User | -| ํฌ์ธํŠธ | Point | -| ์ƒํ’ˆ | Product | -| ๋ธŒ๋žœ๋“œ | Brand | -| ์ข‹์•„์š” | Like | -| ์ฃผ๋ฌธ | Order | -| ์žฌ๊ณ  | Stock | -| ๊ฐ€๊ฒฉ | Price | -| ๊ฒฐ์ œ | Payment | \ No newline at end of file diff --git a/docs/2round/02-sequence-diagrams.md b/docs/2round/02-sequence-diagrams.md deleted file mode 100644 index 5264a4dc0..000000000 --- a/docs/2round/02-sequence-diagrams.md +++ /dev/null @@ -1,164 +0,0 @@ -# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - -### 1. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant ProductController - participant ProductService - participant ProductRepository - participant BrandRepository - participant LikeRepository - - User->>ProductController: GET /api/v1/products - ProductController->>ProductService: getProductList - ProductService->>ProductRepository: findAllWithPaging - ProductService->>BrandRepository: findBrandInfoForProducts() - ProductService->>LikeRepository: countLikesForProducts() - ProductRepository-->>ProductService: productList - ProductService-->>ProductController: productListResponse - ProductController-->>User: 200 OK (์ƒํ’ˆ ๋ชฉ๋ก + ๋ธŒ๋žœ๋“œ + ์ข‹์•„์š” ์ˆ˜) -``` ---- -### 2. ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant ProductController - participant ProductService - participant ProductRepository - participant BrandRepository - participant LikeRepository - - User->>ProductController: GET /api/v1/products/{productId} - ProductController->>ProductService: getProductDetail(productId, userId) - ProductService->>ProductRepository: findById(productId) - ProductService->>BrandRepository: findBrandInfo(brandId) - ProductService->>LikeRepository: existsByUserIdAndProductId(userId, productId) - ProductRepository-->>ProductService: productDetail - ProductService-->>ProductController: productDetailResponse - ProductController-->>User: 200 OK (์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด) -``` ---- -### 3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ -```mermaid -sequenceDiagram - participant User - participant LikeController - participant LikeService - participant LikeRepository - - User->>LikeController: POST /api/v1/like/products/{productId} - LikeController->>LikeService: toggleLike(userId, productId) - LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) - alt ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ - LikeService->>LikeRepository: save(userId, productId) - LikeService-->>LikeController: 201 Created - else ์ด๋ฏธ ์ข‹์•„์š” ๋˜์–ด์žˆ์Œ - LikeService->>LikeRepository: delete(userId, productId) - LikeService-->>LikeController: 204 No Content - end - LikeController-->>User: ์‘๋‹ต (์ƒํƒœ์ฝ”๋“œ์— ๋”ฐ๋ผ ๋‹ค๋ฆ„) -``` ---- - -### 4. ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant BrandController - participant BrandService - participant ProductRepository - participant BrandRepository - - User->>BrandController: GET /api/v1/brands/{brandId}/products - BrandController->>BrandService: getProductsByBrand(brandId, sort, page) - BrandService->>BrandRepository: findById(brandId) - BrandService->>ProductRepository: findByBrandId(brandId, sort, page) - BrandRepository-->>BrandService: brandInfo - ProductRepository-->>BrandService: productList - BrandService-->>BrandController: productListResponse - BrandController-->>User: 200 OK (๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก) -``` ---- -### 5. ์ฃผ๋ฌธ ์ƒ์„ฑ -```mermaid -sequenceDiagram - participant User - participant OrderController - participant OrderService - participant ProductReader - participant StockService - participant PointService - participant OrderRepository - - User->>OrderController: POST /api/v1/orders (items[]) - OrderController->>OrderService: createOrder(userId, items) - OrderService->>ProductReader: getProductsByIds(productIds) - loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด - OrderService->>StockService: checkAndDecreaseStock(productId, quantity) - end - OrderService->>PointService: deductPoint(userId, totalPrice) - alt ์žฌ๊ณ  ๋˜๋Š” ํฌ์ธํŠธ ๋ถ€์กฑ - OrderService-->>OrderController: throw Exception - OrderController-->>User: 400 Bad Request - else ์ •์ƒ - OrderService->>OrderRepository: save(order, orderItems) - OrderService-->>OrderController: OrderResponse - OrderController-->>User: 201 Created (์ฃผ๋ฌธ ์™„๋ฃŒ) - end -``` ---- -### 6. ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ ์ƒ์„ธ ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant OrderController - participant OrderService - participant OrderRepository - participant ProductRepository - - User->>OrderController: GET /api/v1/orders - OrderController->>OrderService: getOrderList(userId) - OrderService->>OrderRepository: findByUserId(userId) - OrderRepository-->>OrderService: orderList - OrderService-->>OrderController: orderListResponse - OrderController-->>User: 200 OK (์ฃผ๋ฌธ ๋ชฉ๋ก) - - User->>OrderController: GET /api/v1/orders/{orderId} - OrderController->>OrderService: getOrderDetail(orderId, userId) - OrderService->>OrderRepository: findById(orderId) - OrderService->>ProductRepository: findProductsInOrder(orderId) - OrderRepository-->>OrderService: orderDetail - OrderService-->>OrderController: orderDetailResponse - OrderController-->>User: 200 OK (์ฃผ๋ฌธ ์ƒ์„ธ) -``` ---- -### 7. ๊ฒฐ์ œ ์ฒ˜๋ฆฌ -```mermaid -sequenceDiagram - participant User - participant PaymentController - participant PaymentService - participant PaymentGateway - participant OrderRepository - participant PointService - participant StockService - - User->>PaymentController: POST /api/v1/payments (orderId) - PaymentController->>PaymentService: processPayment(orderId, userId) - PaymentService->>OrderRepository: findById(orderId) - PaymentService->>PaymentGateway: requestPayment(orderId, amount) - alt ๊ฒฐ์ œ ์„ฑ๊ณต - PaymentGateway-->>PaymentService: SUCCESS - PaymentService->>OrderRepository: updateStatus(orderId, PAID) - PaymentService-->>PaymentController: successResponse - PaymentController-->>User: 200 OK (๊ฒฐ์ œ ์™„๋ฃŒ) - else ๊ฒฐ์ œ ์‹คํŒจ - PaymentGateway-->>PaymentService: FAILED - PaymentService->>PointService: rollbackPoint(userId, amount) - PaymentService->>StockService: restoreStock(orderId) - PaymentService->>OrderRepository: updateStatus(orderId, FAILED) - PaymentController-->>User: 500 Internal Server Error (๊ฒฐ์ œ ์‹คํŒจ) - end -``` diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md deleted file mode 100644 index 8d39cfd0a..000000000 --- a/docs/2round/03-class-diagram.md +++ /dev/null @@ -1,78 +0,0 @@ -# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - -```mermaid -classDiagram -direction TB - -class User { - Long id - String userId - String name - String email - String gender -} - -class Point { - Long id - String userId - Long balance -} - -class Brand { - Long id - String name -} - -class Product { - Long id - Long brandId - String name - Long price - Long likeCount; - Long stock -} - -class Like { - Long id - String userId - Long productId - LocalDateTime createdAt -} - -class Order { - Long id - String userId - Long totalPrice - OrderStatus status - LocalDateTime createdAt - List orderItems -} - -class OrderItem { - Long id - Order order - Long productId - String productName - Long quantity - Long price -} - -class Payment { - Long id - Long orderId - String status - String paymentRequestId - LocalDateTime createdAt -} - -%% ๊ด€๊ณ„ ์„ค์ • -User --> Point -Brand --> Product -Product --> Like -User --> Like -User --> Order -Order --> OrderItem -Order --> Payment -OrderItem --> Product - -``` \ No newline at end of file diff --git a/docs/2round/04-erd.md b/docs/2round/04-erd.md deleted file mode 100644 index 6389b2202..000000000 --- a/docs/2round/04-erd.md +++ /dev/null @@ -1,74 +0,0 @@ -# erd - -```mermaid -erDiagram - USER { - bigint id PK - varchar user_id - varchar name - varchar email - varchar gender - } - - POINT { - bigint id PK - varchar user_id FK - bigint balance - } - - BRAND { - bigint id PK - varchar name - } - - PRODUCT { - bigint id PK - bigint brand_id FK - varchar name - bigint price - bigint like_count - bigint stock - } - - LIKE { - bigint id PK - varchar user_id FK - bigint product_id FK - datetime created_at - } - - ORDERS { - bigint id PK - varchar user_id FK - bigint total_amount - varchar status - datetime created_at - } - - ORDER_ITEM { - bigint id PK - bigint order_id FK - bigint product_id FK - varchar product_name - bigint quantity - bigint price - } - - PAYMENT { - bigint id PK - bigint order_id FK - varchar status - varchar payment_request_id - datetime created_at - } - - %% ๊ด€๊ณ„ (cardinality) - USER ||--|| POINT : "1:1" - BRAND ||--o{ PRODUCT : "1:N" - PRODUCT ||--o{ LIKE : "1:N" - USER ||--o{ LIKE : "1:N" - USER ||--o{ ORDERS : "1:N" - ORDERS ||--o{ ORDER_ITEM : "1:N" - ORDER_ITEM }o--|| PRODUCT : "N:1" - ORDERS ||--|| PAYMENT : "1:1" -``` \ No newline at end of file diff --git a/docs/2round/2round.md b/docs/2round/2round.md deleted file mode 100644 index 84fdc982c..000000000 --- a/docs/2round/2round.md +++ /dev/null @@ -1,37 +0,0 @@ -## โœ๏ธ Design Quest - -> **์ด์ปค๋จธ์Šค ๋„๋ฉ”์ธ(์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๋“ฑ)์— ๋Œ€ํ•œ ์„ค๊ณ„**๋ฅผ ์™„๋ฃŒํ•˜๊ณ , ๋‹ค์Œ ์ฃผ๋ถ€ํ„ฐ ๊ฐœ๋ฐœ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€์˜ ์„ค๊ณ„ ๋ฌธ์„œ๋ฅผ ์ •๋ฆฌํ•˜์—ฌ PR๋กœ ์ œ์ถœํ•ฉ๋‹ˆ๋‹ค. -> - -### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด - -- **์„ค๊ณ„ ๋ฒ”์œ„** - - ์ƒํ’ˆ ๋ชฉ๋ก / ์ƒํ’ˆ ์ƒ์„ธ / ๋ธŒ๋žœ๋“œ ์กฐํšŒ - - ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ (๋ฉฑ๋“ฑ ๋™์ž‘) - - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ๊ฒฐ์ œ ํ๋ฆ„ (์žฌ๊ณ  ์ฐจ๊ฐ, ํฌ์ธํŠธ ์ฐจ๊ฐ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™) -- **์ œ์™ธ ๋„๋ฉ”์ธ** - - ํšŒ์›๊ฐ€์ž…, ํฌ์ธํŠธ ์ถฉ์ „ (1์ฃผ์ฐจ ๊ตฌํ˜„ ์™„๋ฃŒ ๊ธฐ์ค€) -- **์š”๊ตฌ์‚ฌํ•ญ ๊ธฐ๋ฐ˜** - - ๋ฃจํ”„ํŒฉ ์ด์ปค๋จธ์Šค ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฌธ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๋Šฅ/์ œ์•ฝ์‚ฌํ•ญ์„ ์„ค๊ณ„์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. -- **์ œ์ถœ ๋ฐฉ์‹** - 1. ์•„๋ž˜ ํŒŒ์ผ๋“ค์„ ํ”„๋กœ์ ํŠธ ๋‚ด `docs/week2/` ํด๋”์— `.md`๋กœ ์ €์žฅ - 2. Github PR๋กœ ์ œ์ถœ - - PR ์ œ๋ชฉ: `[2์ฃผ์ฐจ] ์„ค๊ณ„ ๋ฌธ์„œ ์ œ์ถœ - ํ™๊ธธ๋™` - - PR ๋ณธ๋ฌธ์— ๋ฆฌ๋ทฐ ํฌ์ธํŠธ ํฌํ•จ (์˜ˆ: ๊ณ ๋ฏผํ•œ ์ง€์  ๋“ฑ) - -### โœ… ์ œ์ถœ ํŒŒ์ผ ๋ชฉ๋ก (.docs/design ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด) - -| ํŒŒ์ผ๋ช… | ๋‚ด์šฉ | -| --- | --- | -| `01-requirements.md` | ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค ๊ธฐ๋ฐ˜ ๊ธฐ๋Šฅ ์ •์˜, ์š”๊ตฌ์‚ฌํ•ญ ๋ช…์„ธ | -| `02-sequence-diagrams.md` | ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ ์ตœ์†Œ 2๊ฐœ ์ด์ƒ (Mermaid ๊ธฐ๋ฐ˜ ์ž‘์„ฑ ๊ถŒ์žฅ) | -| `03-class-diagram.md` | ๋„๋ฉ”์ธ ๊ฐ์ฒด ์„ค๊ณ„ (ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ or ์„ค๋ช… ์ค‘์‹ฌ) | -| `04-erd.md` | ์ „์ฒด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ๋ฐ ๊ด€๊ณ„ ์ •๋ฆฌ (ERD Mermaid ์ž‘์„ฑ ๊ฐ€๋Šฅ) | - -## โœ… Checklist - -- [ ] ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ/์ข‹์•„์š”/์ฃผ๋ฌธ ๋„๋ฉ”์ธ์ด ๋ชจ๋‘ ํฌํ•จ๋˜์–ด ์žˆ๋Š”๊ฐ€? -- [ ] ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์ด ์œ ์ € ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ๋˜์–ด ์žˆ๋Š”๊ฐ€? -- [ ] ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์—์„œ ์ฑ…์ž„ ๊ฐ์ฒด๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š”๊ฐ€? -- [ ] ํด๋ž˜์Šค ๊ตฌ์กฐ๊ฐ€ ๋„๋ฉ”์ธ ์„ค๊ณ„๋ฅผ ์ž˜ ํ‘œํ˜„ํ•˜๊ณ  ์žˆ๋Š”๊ฐ€? -- [ ] ERD ์„ค๊ณ„ ์‹œ ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์„ ๊ณ ๋ คํ•˜์—ฌ ๊ตฌ์„ฑํ•˜์˜€๋Š”๊ฐ€? \ No newline at end of file diff --git a/docs/3round/3round.md b/docs/3round/3round.md deleted file mode 100644 index b9f333cca..000000000 --- a/docs/3round/3round.md +++ /dev/null @@ -1,60 +0,0 @@ -# ๐Ÿ“ Round 3 Quests - ---- - -## ๐Ÿ’ป Implementation Quest - -> *** ๋„๋ฉ”์ธ ๋ชจ๋ธ๋ง**์„ ํ†ตํ•ด Product, Brand, Like, Order ๊ธฐ๋Šฅ์˜ ํ•ต์‹ฌ ๊ฐœ๋…์„ **Entity, Value Object, Domain Service ๋“ฑ ์ ํ•ฉํ•œ** **์ฝ”๋“œ**๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. -* ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ + DIP ๋ฅผ ์ ์šฉํ•ด ์œ ์—ฐํ•˜๊ณ  ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. -* **Application Layer๋ฅผ ๊ฒฝ๋Ÿ‰ ์ˆ˜์ค€**์œผ๋กœ ๊ตฌ์„ฑํ•˜์—ฌ, ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ์‹ค์ œ ๊ตฌํ˜„ํ•ด๋ด…๋‹ˆ๋‹ค. -* **๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑ**ํ•˜์—ฌ ๋„๋ฉ”์ธ ๋กœ์ง์˜ ์ •ํ•ฉ์„ฑ๊ณผ ๊ทœ์น™์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. -> - -### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด - -- ์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ธฐ๋Šฅ์˜ **๋„๋ฉ”์ธ ๋ชจ๋ธ ๋ฐ ๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. -- ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ํ๋ฆ„์„ ์„ค๊ณ„ํ•˜๊ณ , ํ•„์š”ํ•œ ๋กœ์ง์„ **๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋กœ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค. -- Application Layer์—์„œ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. - (์˜ˆ: `ProductFacade.getProductDetail(productId)` โ†’ `Product + Brand + Like ์กฐํ•ฉ`) -- Repository Interface ์™€ ๊ตฌํ˜„์ฒด๋Š” ๋ถ„๋ฆฌํ•˜๊ณ , ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๊ณ ๋ คํ•œ ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค. -- ๋ชจ๋“  ํ•ต์‹ฌ ๋„๋ฉ”์ธ ๋กœ์ง์— ๋Œ€ํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ์˜ˆ์™ธ/๊ฒฝ๊ณ„ ์ผ€์ด์Šค๋„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. - -### ๐Ÿท Product / Brand ๋„๋ฉ”์ธ - -## โœ… Checklist - -- [x] ์ƒํ’ˆ ์ •๋ณด ๊ฐ์ฒด๋Š” ๋ธŒ๋žœ๋“œ ์ •๋ณด, ์ข‹์•„์š” ์ˆ˜๋ฅผ ํฌํ•จํ•œ๋‹ค. -- [x] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค -- [x] ์ƒํ’ˆ์€ ์žฌ๊ณ ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๊ณ , ์ฃผ๋ฌธ ์‹œ ์ฐจ๊ฐํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค -- [x] ์žฌ๊ณ ๋Š” ๊ฐ์†Œ๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ์Œ์ˆ˜ ๋ฐฉ์ง€๋Š” ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ๋œ๋‹ค - -### ๐Ÿ‘ Like ๋„๋ฉ”์ธ - -- [x] ์ข‹์•„์š”๋Š” ์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค -- [x] ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๊ฐ€ ๊ตฌํ˜„๋˜์—ˆ๋‹ค -- [x] ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์กฐํšŒ์—์„œ ํ•จ๊ป˜ ์ œ๊ณต๋œ๋‹ค -- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ/์ค‘๋ณต ๋ฐฉ์ง€ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค - -### ๐Ÿ›’ Order ๋„๋ฉ”์ธ - -- [x] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค -- [x] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ, ์œ ์ € ํฌ์ธํŠธ ์ฐจ๊ฐ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค -- [x] ์žฌ๊ณ  ๋ถ€์กฑ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋“ฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค -- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค - -### ๐Ÿงฉ ๋„๋ฉ”์ธ ์„œ๋น„์Šค - -- [x] ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ๋กœ์ง์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค -- [x] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ ๋„๋ฉ”์ธ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค -- [x] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค -- [x] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค - -### **๐Ÿงฑ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ & ์„ค๊ณ„** - -- [x] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค - - Application โ†’ **Domain** โ† Infrastructure -- [x] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค -- [x] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค -- [x] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค -- [x] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) -- [x] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค \ No newline at end of file From 0074ea918de0aee3995308e4ab57f0fba05428a5 Mon Sep 17 00:00:00 2001 From: BOB <56067193+adminhelper@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:55:58 +0900 Subject: [PATCH 062/164] =?UTF-8?q?Revert=20"[volume-3]=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EB=A7=81=20=EB=B0=8F=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 --- .../application/example/ExampleFacade.java | 17 ++ .../application/example/ExampleInfo.java | 13 ++ .../loopers/application/like/LikeFacade.java | 32 ---- .../application/order/CreateOrderCommand.java | 19 -- .../application/order/OrderFacade.java | 81 --------- .../loopers/application/order/OrderInfo.java | 42 ----- .../application/order/OrderItemCommand.java | 17 -- .../application/order/OrderItemInfo.java | 32 ---- .../application/point/PointFacade.java | 28 --- .../loopers/application/point/PointInfo.java | 13 -- .../product/ProductDetailInfo.java | 32 ---- .../application/product/ProductFacade.java | 47 ----- .../application/product/ProductInfo.java | 33 ---- .../loopers/application/user/UserFacade.java | 27 --- .../loopers/application/user/UserInfo.java | 14 -- .../java/com/loopers/domain/brand/Brand.java | 47 ----- .../loopers/domain/brand/BrandRepository.java | 20 --- .../loopers/domain/brand/BrandService.java | 35 ---- .../loopers/domain/example/ExampleModel.java | 44 +++++ .../domain/example/ExampleRepository.java | 7 + .../domain/example/ExampleService.java | 20 +++ .../java/com/loopers/domain/like/Like.java | 63 ------- .../loopers/domain/like/LikeRepository.java | 25 --- .../com/loopers/domain/like/LikeService.java | 49 ----- .../java/com/loopers/domain/order/Order.java | 86 --------- .../com/loopers/domain/order/OrderItem.java | 91 ---------- .../loopers/domain/order/OrderRepository.java | 21 --- .../loopers/domain/order/OrderService.java | 28 --- .../com/loopers/domain/order/OrderStatus.java | 42 ----- .../java/com/loopers/domain/point/Point.java | 56 ------ .../loopers/domain/point/PointRepository.java | 10 -- .../loopers/domain/point/PointService.java | 43 ----- .../com/loopers/domain/product/Product.java | 120 ------------- .../loopers/domain/product/ProductDetail.java | 45 ----- .../domain/product/ProductDomainService.java | 41 ----- .../domain/product/ProductRepository.java | 29 --- .../domain/product/ProductService.java | 53 ------ .../java/com/loopers/domain/user/User.java | 82 --------- .../loopers/domain/user/UserRepository.java | 10 -- .../com/loopers/domain/user/UserService.java | 30 ---- .../brand/BrandJpaRepository.java | 18 -- .../brand/BrandRepositoryImpl.java | 36 ---- .../example/ExampleJpaRepository.java | 6 + .../example/ExampleRepositoryImpl.java | 19 ++ .../like/LikeJpaRepository.java | 23 --- .../like/LikeRepositoryImpl.java | 46 ----- .../order/OrderJpaRepository.java | 18 -- .../order/OrderRepositoryImpl.java | 36 ---- .../point/PointJpaRepository.java | 11 -- .../point/PointRepositoryImpl.java | 25 --- .../product/ProductJpaRepository.java | 19 -- .../product/ProductRepositoryImpl.java | 59 ------ .../user/UserJpaRepository.java | 11 -- .../user/UserRepositoryImpl.java | 26 --- .../api/example/ExampleV1ApiSpec.java | 19 ++ .../api/example/ExampleV1Controller.java | 28 +++ .../interfaces/api/example/ExampleV1Dto.java | 15 ++ .../interfaces/api/point/PointV1ApiSpec.java | 28 --- .../api/point/PointV1Controller.java | 31 ---- .../interfaces/api/point/PointV1Dto.java | 18 -- .../interfaces/api/user/UserV1ApiSpec.java | 28 --- .../interfaces/api/user/UserV1Controller.java | 31 ---- .../interfaces/api/user/UserV1Dto.java | 24 --- .../com/loopers/domain/brand/BrandTest.java | 42 ----- .../domain/example/ExampleModelTest.java | 65 +++++++ .../ExampleServiceIntegrationTest.java | 72 ++++++++ .../like/LikeServiceIntegrationTest.java | 155 ---------------- .../com/loopers/domain/like/LikeTest.java | 91 ---------- .../order/OrderServiceIntegrationTest.java | 170 ------------------ .../com/loopers/domain/order/OrderTest.java | 122 ------------- .../point/PointServiceIntegrationTest.java | 108 ----------- .../com/loopers/domain/point/PointTest.java | 117 ------------ .../ProductServiceIntegrationTest.java | 43 ----- .../loopers/domain/product/ProductTest.java | 95 ---------- .../user/UserServiceIntegrationTest.java | 112 ------------ .../com/loopers/domain/user/UserTest.java | 53 ------ .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ++++++++++++ .../api/point/PointV1ControllerTest.java | 156 ---------------- .../api/user/UserV1ControllerTest.java | 148 --------------- docs/1round/1round.md | 67 ------- docs/2round/01-requirements.md | 104 ----------- docs/2round/02-sequence-diagrams.md | 164 ----------------- docs/2round/03-class-diagram.md | 78 -------- docs/2round/04-erd.md | 74 -------- docs/2round/2round.md | 37 ---- docs/3round/3round.md | 60 ------- 86 files changed, 439 insertions(+), 3927 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java delete mode 100644 docs/1round/1round.md delete mode 100644 docs/2round/01-requirements.md delete mode 100644 docs/2round/02-sequence-diagrams.md delete mode 100644 docs/2round/03-class-diagram.md delete mode 100644 docs/2round/04-erd.md delete mode 100644 docs/2round/2round.md delete mode 100644 docs/3round/3round.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java new file mode 100644 index 000000000..552a9ad62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java @@ -0,0 +1,17 @@ +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ExampleFacade { + private final ExampleService exampleService; + + public ExampleInfo getExample(Long id) { + ExampleModel example = exampleService.getExample(id); + return ExampleInfo.from(example); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java new file mode 100644 index 000000000..877aba96c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.example; + +import com.loopers.domain.example.ExampleModel; + +public record ExampleInfo(Long id, String name, String description) { + public static ExampleInfo from(ExampleModel model) { + return new ExampleInfo( + model.getId(), + model.getName(), + model.getDescription() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java deleted file mode 100644 index d9dd33205..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.like.LikeService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -/** - * packageName : com.loopers.application.like - * fileName : LikeFacade - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class LikeFacade { - - private final LikeService likeService; - - public void createLike(String userId, Long productId) { - likeService.like(userId, productId); - } - - public void deleteLike(String userId, Long productId) { - likeService.unlike(userId, productId); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java deleted file mode 100644 index 683e39cdd..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.application.order; - -import java.util.List; - -/** - * packageName : com.loopers.application.order - * fileName : CreateOrderCommand - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record CreateOrderCommand( - String userId, - List items -) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java deleted file mode 100644 index 2fba4b4aa..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.OrderStatus; -import com.loopers.domain.point.PointService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.application.order - * fileName : OrderFacade - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Slf4j -@Component -@RequiredArgsConstructor -public class OrderFacade { - - private final OrderService orderService; - private final ProductService productService; - private final PointService pointService; - - @Transactional - public OrderInfo createOrder(CreateOrderCommand command) { - - if (command == null || command.items() == null || command.items().isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์ •๋ณด๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"); - } - - Order order = Order.create(command.userId()); - - for (OrderItemCommand itemCommand : command.items()) { - - //์ƒํ’ˆ๊ฐ€์ ธ์˜ค๊ณ  - Product product = productService.getProduct(itemCommand.productId()); - - // ์žฌ๊ณ ๊ฐ์†Œ - product.decreaseStock(itemCommand.quantity()); - - // OrderItem์ƒ์„ฑ - OrderItem orderItem = OrderItem.create( - product.getId(), - product.getName(), - itemCommand.quantity(), - product.getPrice()); - - order.addOrderItem(orderItem); - orderItem.setOrder(order); - } - - //์ด ๊ฐ€๊ฒฉ๊ตฌํ•˜๊ณ  - long totalAmount = order.getOrderItems().stream() - .mapToLong(OrderItem::getAmount) - .sum(); - - order.updateTotalAmount(totalAmount); - - pointService.usePoint(command.userId(), totalAmount); - - //์ €์žฅ - Order saved = orderService.createOrder(order); - saved.updateStatus(OrderStatus.COMPLETE); - - return OrderInfo.from(saved); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java deleted file mode 100644 index 70028c27c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderStatus; - -import java.time.LocalDateTime; -import java.util.List; - -/** - * packageName : com.loopers.application.order - * fileName : OrderInfo - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record OrderInfo( - Long orderId, - String userId, - Long totalAmount, - OrderStatus status, - LocalDateTime createdAt, - List items -) { - public static OrderInfo from(Order order) { - List itemInfos = order.getOrderItems().stream() - .map(OrderItemInfo::from) - .toList(); - - return new OrderInfo( - order.getId(), - order.getUserId(), - order.getTotalAmount(), - order.getStatus(), - order.getCreatedAt(), - itemInfos - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java deleted file mode 100644 index 1ac46862f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.order; - -/** - * packageName : com.loopers.application.order - * fileName : OrderItemCommand - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record OrderItemCommand( - Long productId, - Long quantity -) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java deleted file mode 100644 index b3f2359c6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.OrderItem; - -/** - * packageName : com.loopers.application.order - * fileName : OrderInfo - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record OrderItemInfo( - Long productId, - String productName, - Long quantity, - Long price, - Long amount -) { - public static OrderItemInfo from(OrderItem item) { - return new OrderItemInfo( - item.getProductId(), - item.getProductName(), - item.getQuantity(), - item.getPrice(), - item.getAmount() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java deleted file mode 100644 index 009be1cec..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.application.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointService; -import com.loopers.interfaces.api.point.PointV1Dto; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class PointFacade { - private final PointService pointService; - - public PointInfo getPoint(String userId) { - Point point = pointService.findPointByUserId(userId); - - if (point == null) { - throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - return PointInfo.from(point); - } - - public PointInfo chargePoint(PointV1Dto.ChargePointRequest request) { - return PointInfo.from(pointService.chargePoint(request.userId(), request.chargeAmount())); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java deleted file mode 100644 index 65497297b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.point; - -import com.loopers.domain.point.Point; - -public record PointInfo(String userId, Long amount) { - public static PointInfo from(Point info) { - return new PointInfo( - info.getUserId(), - info.getBalance() - ); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java deleted file mode 100644 index 2a9ecee27..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.product.ProductDetail; - -/** - * packageName : com.loopers.application.product - * fileName : ProductDetail - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record ProductDetailInfo( - Long id, - String name, - String brandName, - Long price, - Long likeCount -) { - public static ProductDetailInfo from(ProductDetail productDetail) { - return new ProductDetailInfo( - productDetail.getId(), - productDetail.getName(), - productDetail.getBrandName(), - productDetail.getPrice(), - productDetail.getLikeCount() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java deleted file mode 100644 index e6a25de23..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; -import com.loopers.domain.product.ProductDetail; -import com.loopers.domain.product.ProductDomainService; -import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -/** - * packageName : com.loopers.application.product - * fileName : ProdcutFacade - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class ProductFacade { - - private final ProductService productService; - private final BrandService brandService; - private final LikeService likeService; - private final ProductDomainService productDomainService; - - public Page getProducts(String sort, Pageable pageable) { - return productService.getProducts(sort ,pageable) - .map(product -> { - Brand brand = brandService.getBrand(product.getBrandId()); - long likeCount = likeService.countByProductId(product.getId()); - return ProductInfo.of(product, brand, likeCount); - }); - } - - public ProductDetailInfo getProduct(Long id) { - ProductDetail productDetail = productDomainService.getProductDetail(id); - return ProductDetailInfo.from(productDetail); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java deleted file mode 100644 index 8bcd93dd8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; - -/** - * packageName : com.loopers.application.product - * fileName : ProductInfo - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public record ProductInfo( - Long id, - String name, - String brandName, - Long price, - Long likeCount -) { - public static ProductInfo of(Product product, Brand brand, Long likeCount) { - return new ProductInfo( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice(), - likeCount - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java deleted file mode 100644 index f42bd5206..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class UserFacade { - private final UserService userService; - - public UserInfo register(String userId, String email, String birth, String gender) { - User user = userService.register(userId, email, birth, gender); - return UserInfo.from(user); - } - - public UserInfo getUser(String userId) { - User user = userService.findUserByUserId(userId); - if (user == null) { - throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - return UserInfo.from(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java deleted file mode 100644 index 08f5cea43..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; - -public record UserInfo(String userId, String email, String birth, String gender) { - public static UserInfo from(User user) { - return new UserInfo( - user.getUserId(), - user.getEmail(), - user.getBirth(), - user.getGender() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java deleted file mode 100644 index d334ccebf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -/** - * packageName : com.loopers.domain.brand - * fileName : Brand - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Entity -@Table(name = "brand") -@Getter -public class Brand { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false) - private String name; - - protected Brand() {} - - private Brand(String name) { - this.name = requireValidName(name); - } - - public static Brand create(String name) { - return new Brand(name); - } - - - private String requireValidName(String name) { - if (name == null || name.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return name.trim(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java deleted file mode 100644 index c558b23fc..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.brand; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.brand - * fileName : BrandRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface BrandRepository { - Optional findById(Long id); - - void save(Brand brand); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java deleted file mode 100644 index e0f58c77b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.brand - * fileName : BrandService - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class BrandService { - - private final BrandRepository brandRepository; - - public void save(Brand brand) { - brandRepository.save(brand); - } - - @Transactional(readOnly = true) - public Brand getBrand(Long id) { - return brandRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java new file mode 100644 index 000000000..c588c4a8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -0,0 +1,44 @@ +package com.loopers.domain.example; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "example") +public class ExampleModel extends BaseEntity { + + private String name; + private String description; + + protected ExampleModel() {} + + public ExampleModel(String name, String description) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (description == null || description.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public void update(String newDescription) { + if (newDescription == null || newDescription.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + this.description = newDescription; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java new file mode 100644 index 000000000..3625e5662 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java @@ -0,0 +1,7 @@ +package com.loopers.domain.example; + +import java.util.Optional; + +public interface ExampleRepository { + Optional find(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java new file mode 100644 index 000000000..c0e8431e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java @@ -0,0 +1,20 @@ +package com.loopers.domain.example; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ExampleService { + + private final ExampleRepository exampleRepository; + + @Transactional(readOnly = true) + public ExampleModel getExample(Long id) { + return exampleRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] ์˜ˆ์‹œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java deleted file mode 100644 index 4430b496a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -import java.time.LocalDateTime; - -/** - * packageName : com.loopers.domain.like - * fileName : Like - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Entity -@Table(name = "product_like") -@Getter -public class Like { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "ref_user_id", nullable = false) - private String userId; - - @Column(name = "ref_product_id", nullable = false) - private Long productId; - - @Column(nullable = false) - private LocalDateTime createdAt; - - protected Like() {} - - private Like(String userId, Long productId) { - this.userId = requireValidUserId(userId); - this.productId = requireValidProductId(productId); - this.createdAt = LocalDateTime.now(); - } - - public static Like create(String userId, Long productId) { - return new Like(userId, productId); - } - - private String requireValidUserId(String userId) { - if (userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return userId; - } - - private Long requireValidProductId(Long productId) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return productId; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java deleted file mode 100644 index 945b10235..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.domain.like; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.like - * fileName : LikeRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface LikeRepository { - - Optional findByUserIdAndProductId(String userId, Long productId); - - void save(Like like); - - void delete(Like like); - - long countByProductId(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java deleted file mode 100644 index 41ae90b6a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.domain.product.ProductRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.application.like - * fileName : LikeService - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class LikeService { - - private final LikeRepository likeRepository; - private final ProductRepository productRepository; - - @Transactional - public void like(String userId, Long productId) { - if (likeRepository.findByUserIdAndProductId(userId, productId).isPresent()) return; - - Like like = Like.create(userId, productId); - likeRepository.save(like); - productRepository.incrementLikeCount(productId); - } - - @Transactional - public void unlike(String userId, Long productId) { - likeRepository.findByUserIdAndProductId(userId, productId) - .ifPresent(like -> { - likeRepository.delete(like); - productRepository.decrementLikeCount(productId); - }); - } - - @Transactional(readOnly = true) - public long countByProductId(Long productId) { - return likeRepository.countByProductId(productId); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java deleted file mode 100644 index 84f299c6b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; - -/** - * packageName : com.loopers.domain.order - * fileName : Order - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Entity -@Table(name = "orders") -@Getter -public class Order { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "ref_user_id", nullable = false) - private String userId; - - @Column(nullable = false) - private Long totalAmount; - - @Enumerated(EnumType.STRING) - private OrderStatus status; - - @Column(nullable = false) - private LocalDateTime createdAt; - - @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) - private List orderItems = new ArrayList<>(); - - protected Order() {} - - private Order(String userId, OrderStatus status) { - this.userId = requiredValidUserId(userId); - this.totalAmount = 0L; - this.status = requiredValidStatus(status); - this.createdAt = LocalDateTime.now(); - } - - public static Order create(String userId) { - return new Order(userId, OrderStatus.PENDING); - } - - public void addOrderItem(OrderItem orderItem) { - orderItem.setOrder(this); - this.orderItems.add(orderItem); - } - - private OrderStatus requiredValidStatus(OrderStatus status) { - if (status == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ƒํƒœ๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); - } - return status; - } - - private String requiredValidUserId(String userId) { - if (userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); - } - return userId; - } - - public void updateTotalAmount(long totalAmount) { - this.totalAmount = totalAmount; - } - - public void updateStatus(OrderStatus status) { - this.status = status; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java deleted file mode 100644 index dce97a44a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderItem - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Entity -@Table(name = "order_item") -@Getter -public class OrderItem { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Setter - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "order_id", nullable = false) - private Order order; - - @Column(name = "ref_product_id", nullable = false) - private Long productId; - - @Column(name = "ref_product_name", nullable = false) - private String productName; - - @Column(nullable = false) - private Long quantity; - - @Column(nullable = false) - private Long price; - - protected OrderItem() {} - - private OrderItem(Long productId, String productName, Long quantity, Long price) { - this.productId = requiredValidProductId(productId); - this.productName = requiredValidProductName(productName); - this.quantity = requiredQuantity(quantity); - this.price = requiredPrice(price); - } - - public static OrderItem create(Long productId, String productName, Long quantity, Long price) { - return new OrderItem(productId, productName, quantity, price); - } - - public Long getAmount() { - return quantity * price; - } - - private Long requiredValidProductId(Long productId) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return productId; - } - - private String requiredValidProductName(String productName) { - if (productName == null || productName.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return productName; - } - - private Long requiredQuantity(Long quantity) { - if (quantity == null || quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return quantity; - } - - private Long requiredPrice(Long price) { - if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return price; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java deleted file mode 100644 index c80262041..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.domain.order; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderRepository - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface OrderRepository { - - Order save(Order order); - - Optional findById(Long orderId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java deleted file mode 100644 index a66be03d3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.domain.order; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderService - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class OrderService { - - private final OrderRepository orderRepository; - - @Transactional - public Order createOrder(Order order) { - return orderRepository.save(order); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java deleted file mode 100644 index 14ea592ef..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain.order; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderStatus - * author : byeonsungmun - * date : 2025. 11. 11. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public enum OrderStatus { - - COMPLETE("๊ฒฐ์ œ์„ฑ๊ณต"), - CANCEL("๊ฒฐ์ œ์ทจ์†Œ"), - FAIL("๊ฒฐ์ œ์‹คํŒจ"), - PENDING("๊ฒฐ์ œ์ค‘"); - - private final String description; - - OrderStatus(String description) { - this.description = description; - } - - public boolean isCompleted() { - return this == COMPLETE; - } - - public boolean isPending() { - return this == PENDING; - } - - public boolean isCanceled() { - return this == CANCEL; - } - - public String description() { - return description; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java deleted file mode 100644 index bc28a902a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -@Entity -@Table(name = "point") -@Getter -public class Point extends BaseEntity { - - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Id - private Long id; - - private String userId; - - private Long balance; - - protected Point() {} - - private Point(String userId, Long balance) { - this.userId = requireValidUserId(userId); - this.balance = balance; - } - - public static Point create(String userId, Long balance) { - return new Point(userId, balance); - } - - String requireValidUserId(String userId) { - if(userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return userId; - } - - public void charge(Long chargeAmount) { - if (chargeAmount == null || chargeAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - this.balance += chargeAmount; - } - - public void use(Long useAmount) { - if (useAmount == null || useAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (this.balance < useAmount) { - throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - this.balance -= useAmount; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java deleted file mode 100644 index 314022491..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain.point; - -import java.util.Optional; - -public interface PointRepository { - - Optional findByUserId(String userId); - - Point save(Point point); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java deleted file mode 100644 index 9c9570615..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class PointService { - - private final PointRepository pointRepository; - - @Transactional(readOnly = true) - public Point findPointByUserId(String userId) { - return pointRepository.findByUserId(userId).orElse(null); - } - - @Transactional - public Point chargePoint(String userId, Long chargeAmount) { - Point point = pointRepository.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ• ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); - point.charge(chargeAmount); - return pointRepository.save(point); - } - - @Transactional - public Point usePoint(String userId, Long useAmount) { - Point point = pointRepository.findByUserId(userId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - if (useAmount == null || useAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - if (point.getBalance() < useAmount) { - throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - - point.use(useAmount); - return pointRepository.save(point); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java deleted file mode 100644 index 29968402f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -/** - * packageName : com.loopers.domain.product - * fileName : Product - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Entity -@Table(name = "product") -@Getter -public class Product { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "ref_brand_id", nullable = false) - private Long brandId; - - @Column(nullable = false) - private String name; - - @Column(nullable = false) - private Long price; - - @Column - private Long likeCount; - - @Column(nullable = false) - private Long stock; - - protected Product() {} - - private Product(Long brandId, String name, Long price, Long likeCount, Long stock) { - this.brandId = requireValidBrandId(brandId); - this.name = requireValidName(name); - this.price = requireValidPrice(price); - this.likeCount = requireValidLikeCount(likeCount); - this.stock = requireValidStock(stock); - } - - public static Product create(Long brandId, String name, Long price, Long stock) { - return new Product( - brandId, - name, - price, - 0L, - stock - ); - } - - private Long requireValidBrandId(Long brandId) { - if (brandId == null || brandId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - - return brandId; - } - - private String requireValidName(String name) { - if (name == null || name.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - return name; - } - - private Long requireValidPrice(Long price) { - if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return price; - } - - private Long requireValidLikeCount(Long likeCount) { - if (likeCount == null || likeCount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return likeCount; - } - - private Long requireValidStock(Long stock) { - if (stock == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - if (stock < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” 0 ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - return stock; - } - - public void increaseLikeCount() { - this.likeCount++; - } - - public void decreaseLikeCount() { - if (this.likeCount > 0) this.likeCount--; - } - - public void decreaseStock(Long quantity) { - if (quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (this.stock - quantity < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - this.stock -= quantity; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java deleted file mode 100644 index 808bff196..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.brand.Brand; -import lombok.Getter; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductDetail - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Getter -public class ProductDetail { - - private Long id; - private String name; - private String brandName; - private Long price; - private Long likeCount; - - protected ProductDetail() {} - - private ProductDetail(Long id, String name, String brandName, Long price, Long likeCount) { - this.id = id; - this.name = name; - this.brandName = brandName; - this.price = price; - this.likeCount = likeCount; - } - - public static ProductDetail of(Product product, Brand brand, Long likeCount) { - return new ProductDetail( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice(), - likeCount - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java deleted file mode 100644 index 166aff66b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.like.LikeRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductDetailService - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class ProductDomainService { - - private final ProductRepository productRepository; - private final BrandRepository brandRepository; - private final LikeRepository likeRepository; - - @Transactional(readOnly = true) - public ProductDetail getProductDetail(Long id) { - Product product = productRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - Brand brand = brandRepository.findById(product.getBrandId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); - long likeCount = likeRepository.countByProductId(id); - - return ProductDetail.of(product, brand, likeCount); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java deleted file mode 100644 index dadda62a0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.loopers.domain.product; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.Optional; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductRepositroy - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface ProductRepository { - Page findAll(Pageable pageable); - - Optional findById(Long id); - - void incrementLikeCount(Long productId); - - void decrementLikeCount(Long productId); - - Product save(Product product); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java deleted file mode 100644 index 067f194ae..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductService - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@Component -@RequiredArgsConstructor -public class ProductService { - - private final ProductRepository productRepository; - - @Transactional(readOnly = true) - public Page getProducts(String sort, Pageable pageable) { - Sort sortOption = switch (sort) { - case "price_asc" -> Sort.by("price").ascending(); - case "likes_desc" -> Sort.by("likeCount").descending(); - default -> Sort.by("createdAt").descending(); // latest - }; - - Pageable sortedPageable = PageRequest.of( - pageable.getPageNumber(), - pageable.getPageSize(), - sortOption - ); - - return productRepository.findAll(sortedPageable); - } - - public Product getProduct(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค")); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java deleted file mode 100644 index 287b84cf8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; - -import java.util.regex.Pattern; - -@Entity -@Table(name = "user") -@Getter -public class User extends BaseEntity { - - private static final Pattern USERID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); - private static final Pattern BIRTH_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); - - @Column(unique = true, nullable = false) - private String userId; - - @Column(nullable = false) - private String email; - - @Column(nullable = false) - private String birth; - - @Column(nullable = false) - private String gender; - - protected User() {} - - public User(String userId, String email, String birth, String gender) { - this.userId = requireValidUserId(userId); - this.email = requireValidEmail(email); - this.birth = requireValidBirthDate(birth); - this.gender = requireValidGender(gender); - } - - String requireValidUserId(String userId) { - if(userId == null || userId.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if (!USERID_PATTERN.matcher(userId).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return userId; - } - - String requireValidEmail(String email) { - if(email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if(!EMAIL_PATTERN.matcher(email).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์— ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ex)xx@yy.zz"); - } - return email; - } - - String requireValidBirthDate(String birth) { - if (birth == null || birth.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - if(!BIRTH_PATTERN.matcher(birth).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - return birth; - } - - String requireValidGender(String gender) { - if(gender == null || gender.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ฑ๋ณ„์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); - } - return gender; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java deleted file mode 100644 index f4b26266e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain.user; - -import java.util.Optional; - -public interface UserRepository { - - Optional findByUserId(String userId); - - User save(User user); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java deleted file mode 100644 index 3cc033076..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class UserService { - - private final UserRepository userRepository; - - @Transactional - public User register(String userId, String email, String birth, String gender) { - userRepository.findByUserId(userId).ifPresent(user -> { - throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); - }); - - User user = new User(userId, email, birth, gender); - return userRepository.save(user); - } - - @Transactional(readOnly = true) - public User findUserByUserId(String userId) { - return userRepository.findByUserId(userId).orElse(null); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java deleted file mode 100644 index 759f3caf1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.infrastructure.brand; - -import com.loopers.domain.brand.Brand; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * packageName : com.loopers.infrastructure.brand - * fileName : BrandJpaRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface BrandJpaRepository extends JpaRepository { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java deleted file mode 100644 index f23e6e5d9..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.loopers.infrastructure.brand; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.brand - * fileName : BrandRepositroyImpl - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@RequiredArgsConstructor -@Component -public class BrandRepositoryImpl implements BrandRepository { - - private final BrandJpaRepository jpaRepository; - - @Override - public Optional findById(Long id) { - return jpaRepository.findById(id); - } - - @Override - public void save(Brand brand) { - jpaRepository.save(brand); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java new file mode 100644 index 000000000..ce6d3ead0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.example; + +import com.loopers.domain.example.ExampleModel; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java new file mode 100644 index 000000000..37f2272f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.example; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.domain.example.ExampleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ExampleRepositoryImpl implements ExampleRepository { + private final ExampleJpaRepository exampleJpaRepository; + + @Override + public Optional find(Long id) { + return exampleJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java deleted file mode 100644 index 865a30db7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.loopers.infrastructure.like; - -import com.loopers.domain.like.Like; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.like - * fileName : LikeJpaRepository - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface LikeJpaRepository extends JpaRepository { - Optional findByUserIdAndProductId(String userId, Long productId); - - long countByProductId(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java deleted file mode 100644 index e037b6efb..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.infrastructure.like; - -import com.loopers.domain.like.Like; -import com.loopers.domain.like.LikeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.like - * fileName : LikeRepositoryImpl - * author : byeonsungmun - * date : 2025. 11. 12. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class LikeRepositoryImpl implements LikeRepository { - - private final LikeJpaRepository likeJpaRepository; - - @Override - public Optional findByUserIdAndProductId(String userId, Long productId) { - return likeJpaRepository.findByUserIdAndProductId(userId, productId); - } - - @Override - public void save(Like like) { - likeJpaRepository.save(like); - } - - @Override - public void delete(Like like) { - likeJpaRepository.delete(like); - } - - @Override - public long countByProductId(Long productId) { - return likeJpaRepository.countByProductId(productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java deleted file mode 100644 index 39cfb136d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.Order; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * packageName : com.loopers.infrastructure.order - * fileName : OrderJpaRepository - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface OrderJpaRepository extends JpaRepository { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java deleted file mode 100644 index f8c7b5b68..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.order - * fileName : OrderRepositroyImpl - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class OrderRepositoryImpl implements OrderRepository { - - private final OrderJpaRepository orderJpaRepository; - - @Override - public Order save(Order order) { - return orderJpaRepository.save(order); - } - - @Override - public Optional findById(Long orderId) { - return orderJpaRepository.findById(orderId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java deleted file mode 100644 index a35a56151..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface PointJpaRepository extends JpaRepository { - - Optional findByUserId(String userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java deleted file mode 100644 index 530191b66..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class PointRepositoryImpl implements PointRepository { - - private final PointJpaRepository pointJpaRepository; - - @Override - public Optional findByUserId(String userId) { - return pointJpaRepository.findByUserId(userId); - } - - @Override - public Point save(Point point) { - return pointJpaRepository.save(point); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java deleted file mode 100644 index 5ceaae067..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.product; - -import com.loopers.domain.product.Product; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * packageName : com.loopers.infrastructure.product - * fileName : ProductJpaRepository - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -public interface ProductJpaRepository extends JpaRepository { - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java deleted file mode 100644 index dbad0d9d5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.loopers.infrastructure.product; - -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -/** - * packageName : com.loopers.infrastructure.product - * fileName : ProductRepositoryImpl - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@Component -@RequiredArgsConstructor -public class ProductRepositoryImpl implements ProductRepository { - - private final ProductJpaRepository productJpaRepository; - - @Override - public Page findAll(Pageable pageable) { - return productJpaRepository.findAll(pageable); - } - - @Override - public Optional findById(Long id) { - return productJpaRepository.findById(id); - } - - @Override - public void incrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - product.increaseLikeCount(); - } - - @Override - public void decrementLikeCount(Long productId) { - Product product = productJpaRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - product.decreaseLikeCount(); - } - - @Override - public Product save(Product product) { - return productJpaRepository.save(product); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java deleted file mode 100644 index f80a5bc52..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserJpaRepository extends JpaRepository { - - Optional findByUserId(String userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java deleted file mode 100644 index 8fb6f7bdf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class UserRepositoryImpl implements UserRepository { - - private final UserJpaRepository userJpaRepository; - - @Override - public Optional findByUserId(String userId) { - return userJpaRepository.findByUserId(userId); - } - - @Override - public User save(User user) { - return userJpaRepository.save(user); - } -} - diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java new file mode 100644 index 000000000..219e3101e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Example V1 API", description = "Loopers ์˜ˆ์‹œ API ์ž…๋‹ˆ๋‹ค.") +public interface ExampleV1ApiSpec { + + @Operation( + summary = "์˜ˆ์‹œ ์กฐํšŒ", + description = "ID๋กœ ์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getExample( + @Schema(name = "์˜ˆ์‹œ ID", description = "์กฐํšŒํ•  ์˜ˆ์‹œ์˜ ID") + Long exampleId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java new file mode 100644 index 000000000..917376016 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleFacade; +import com.loopers.application.example.ExampleInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/examples") +public class ExampleV1Controller implements ExampleV1ApiSpec { + + private final ExampleFacade exampleFacade; + + @GetMapping("/{exampleId}") + @Override + public ApiResponse getExample( + @PathVariable(value = "exampleId") Long exampleId + ) { + ExampleInfo info = exampleFacade.getExample(exampleId); + ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java new file mode 100644 index 000000000..4ecf0eea5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.example; + +import com.loopers.application.example.ExampleInfo; + +public class ExampleV1Dto { + public record ExampleResponse(Long id, String name, String description) { + public static ExampleResponse from(ExampleInfo info) { + return new ExampleResponse( + info.id(), + info.name(), + info.description() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java deleted file mode 100644 index 6f0458399..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Point V1 API", description = "Point API ์ž…๋‹ˆ๋‹ค.") -public interface PointV1ApiSpec { - - @Operation( - summary = "ํฌ์ธํŠธ ํšŒ์› ์กฐํšŒ", - description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•œ๋‹ค." - ) - ApiResponse getPoint( - @Schema(name = "ํšŒ์› Id", description = "์กฐํšŒํ•  ํšŒ์› ID") - String userId - ); - - @Operation( - summary = "ํฌ์ธํŠธ ์ถฉ์ „", - description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•œ๋‹ค." - ) - ApiResponse chargePoint( - @Schema(name = "ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ", description = "์ถฉ์ „ํ•  ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์š”์ฒญ") - PointV1Dto.ChargePointRequest request - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java deleted file mode 100644 index 866fce9b3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.application.point.PointFacade; -import com.loopers.application.point.PointInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/points") -public class PointV1Controller implements PointV1ApiSpec { - - private final PointFacade pointFacade; - - @Override - @GetMapping - public ApiResponse getPoint(@RequestHeader("X-USER-ID") String userId) { - PointInfo pointInfo = pointFacade.getPoint(userId); - PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); - return ApiResponse.success(response); - } - - @Override - @PatchMapping("/charge") - public ApiResponse chargePoint(@RequestBody PointV1Dto.ChargePointRequest request) { - PointInfo pointInfo = pointFacade.chargePoint(request); - PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java deleted file mode 100644 index b0b3d050e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.application.point.PointInfo; - -public class PointV1Dto { - - public record ChargePointRequest(String userId, Long chargeAmount) { - } - - public record PointResponse(String userId, Long amount) { - public static PointResponse from(PointInfo info) { - return new PointResponse( - info.userId(), - info.amount() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java deleted file mode 100644 index 1bed68e62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Users V1 API", description = "Users API ์ž…๋‹ˆ๋‹ค.") -public interface UserV1ApiSpec { - - @Operation( - summary = "ํšŒ์› ๊ฐ€์ž…", - description = "ํšŒ์› ๊ฐ€์ž…์„ ํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse register( - @Schema(name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", description = "ํšŒ์›๊ฐ€์ž…") - UserV1Dto.RegisterRequest request - ); - - @Operation( - summary = "ํšŒ์› ์กฐํšŒ", - description = "ํ•ด๋‹น ํšŒ์›์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getUser( - @Schema(name = "ํšŒ์› ID", description = "์กฐํšŒํ•  ํšŒ์› ID") - String userId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java deleted file mode 100644 index aed39ae1f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserFacade; -import com.loopers.application.user.UserInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/users") -public class UserV1Controller implements UserV1ApiSpec { - - private final UserFacade userFacade; - - @Override - @PostMapping("/register") - public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { - UserInfo userInfo = userFacade.register(request.userId(), request.mail(), request.birth(), request.gender()); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); - return ApiResponse.success(response); - } - - @Override - @GetMapping("/{userId}") - public ApiResponse getUser(@PathVariable String userId) { - UserInfo userInfo = userFacade.getUser(userId); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java deleted file mode 100644 index 263214848..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserInfo; - -public class UserV1Dto { - public record RegisterRequest( - String userId, - String mail, - String birth, - String gender - ) { - } - - public record UserResponse(String userId, String email, String birth, String gender) { - public static UserResponse from(UserInfo info) { - return new UserResponse( - info.userId(), - info.email(), - info.birth(), - info.gender() - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java deleted file mode 100644 index 9541c11f4..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * packageName : com.loopers.domain.brand - * fileName : BrandTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class BrandTest { - - @DisplayName("Brand ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") - @Nested - class CreateBrandTest { - - @Test - @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ฑ ์„ฑ๊ณต") - void createBrandSuccess() { - Brand brand = Brand.create("Nike"); - assertThat(brand.getName()).isEqualTo("Nike"); - } - - @Test - @DisplayName("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ") - void createBrandFail() { - assertThatThrownBy(() -> Brand.create("")) - .isInstanceOf(CoreException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java new file mode 100644 index 000000000..44ca7576e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -0,0 +1,65 @@ +package com.loopers.domain.example; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ExampleModelTest { + @DisplayName("์˜ˆ์‹œ ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") + @Nested + class Create { + @DisplayName("์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + @Test + void createsExampleModel_whenNameAndDescriptionAreProvided() { + // arrange + String name = "์ œ๋ชฉ"; + String description = "์„ค๋ช…"; + + // act + ExampleModel exampleModel = new ExampleModel(name, description); + + // assert + assertAll( + () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getName()).isEqualTo(name), + () -> assertThat(exampleModel.getDescription()).isEqualTo(description) + ); + } + + @DisplayName("์ œ๋ชฉ์ด ๋นˆ์นธ์œผ๋กœ๋งŒ ์ด๋ฃจ์–ด์ ธ ์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenTitleIsBlank() { + // arrange + String name = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ExampleModel(name, "์„ค๋ช…"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsBadRequestException_whenDescriptionIsEmpty() { + // arrange + String description = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ExampleModel("์ œ๋ชฉ", description); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java new file mode 100644 index 000000000..bbd5fdbe1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java @@ -0,0 +1,72 @@ +package com.loopers.domain.example; + +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ExampleServiceIntegrationTest { + @Autowired + private ExampleService exampleService; + + @Autowired + private ExampleJpaRepository exampleJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") + @Nested + class Get { + @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange + ExampleModel exampleModel = exampleJpaRepository.save( + new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") + ); + + // act + ExampleModel result = exampleService.getExample(exampleModel.getId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), + () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), + () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidIdIsProvided() { + // arrange + Long invalidId = 999L; // Assuming this ID does not exist + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + exampleService.getExample(invalidId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java deleted file mode 100644 index 0be07a6fb..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ /dev/null @@ -1,155 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -import static org.assertj.core.api.Assertions.*; - -/** - * packageName : com.loopers.domain.like - * fileName : LikeServiceIntegrationTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@SpringBootTest -class LikeServiceIntegrationTest { - - @Autowired - private LikeService likeService; - - @Autowired - private LikeRepository likeRepository; - - @Autowired - private ProductRepository productRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private DatabaseCleanUp cleanUp; - - @AfterEach - void tearDown() { - cleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("์ข‹์•„์š” ๊ธฐ๋Šฅ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - class LikeTests { - - @Test - @DisplayName("์ข‹์•„์š” ์ƒ์„ฑ ์„ฑ๊ณต โ†’ ์ข‹์•„์š” ์ €์žฅ + ์ƒํ’ˆ์˜ likeCount ์ฆ๊ฐ€") - @Transactional - void likeSuccess() { - // given - User user = userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - Product product = productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - // when - likeService.like(user.getUserId(), product.getId()); - - // then - Like saved = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); - assertThat(saved).isNotNull(); - - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(1L); - } - - @Test - @DisplayName("์ค‘๋ณต ์ข‹์•„์š” ์‹œ likeCount ์ฆ๊ฐ€ ์•ˆ ํ•˜๊ณ  ์ €์žฅ๋„ ์•ˆ ๋จ") - @Transactional - void duplicateLike() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - likeService.like("user1", 1L); - - // when - likeService.like("user1", 1L); // ์ค‘๋ณต ํ˜ธ์ถœ - - // then - long likeCount = likeRepository.countByProductId(1L); - assertThat(likeCount).isEqualTo(1L); - - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(1L); // ์ฆ๊ฐ€ X - } - - @Test - @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ์„ฑ๊ณต โ†’ like ์‚ญ์ œ + ์ƒํ’ˆ์˜ likeCount ๊ฐ์†Œ") - @Transactional - void unlikeSuccess() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - likeService.like("user1", 1L); - - // when - likeService.unlike("user1", 1L); - - // then - Like like = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); - assertThat(like).isNull(); - - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(0L); - } - - @Test - @DisplayName("์—†๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ likeCount ๊ฐ์†Œ ์•ˆ ํ•จ") - @Transactional - void unlikeNonExisting() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - Product product = Product.create(1L, "์ƒํ’ˆA", 1000L, 10L); - product.increaseLikeCount(); - product.increaseLikeCount(); - product.increaseLikeCount(); - product.increaseLikeCount(); - product.increaseLikeCount(); - - productRepository.save(product); - // when โ€” ํ˜ธ์ถœ์€ ํ•ด๋„ - likeService.unlike("user1", 1L); - - // then โ€” ๋ณ€ํ™” ์—†์Œ - Product updated = productRepository.findById(1L).get(); - assertThat(updated.getLikeCount()).isEqualTo(5L); - } - - @Test - @DisplayName("countByProductId ์ •์ƒ ์กฐํšŒ") - @Transactional - void countTest() { - // given - userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); - userRepository.save(new User("user2", "u2@mail.com", "1991-01-01", "MALE")); - productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); - - likeService.like("user1", 1L); - likeService.like("user2", 1L); - - // when - long count = likeService.countByProductId(1L); - - // then - assertThat(count).isEqualTo(2L); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java deleted file mode 100644 index d5b8bd851..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.loopers.domain.like; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * packageName : com.loopers.domain.like - * fileName : LikeTest - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class LikeTest { - - - @DisplayName("์ •์ƒ์ ์œผ๋กœ Like ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ƒ์„ฑ์ˆ˜ ํ•  ์žˆ๋‹ค") - @Nested - class LikeCreate { - - @DisplayName("Like์ƒ์„ฑ์ž๋ฅผ ์ƒ์„ฑํ•œ๋‹ค") - @Test - void createLike_success() { - // given - String userId = "user-001"; - Long productId = 100L; - - // when - Like like = Like.create(userId, productId); - - // then - assertThat(like.getUserId()).isEqualTo(userId); - assertThat(like.getProductId()).isEqualTo(productId); - assertThat(like.getCreatedAt()).isBeforeOrEqualTo(LocalDateTime.now()); - } - - @Test - @DisplayName("userId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidUserId_null() { - // given - String userId = null; - Long productId = 100L; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - - @Test - @DisplayName("userId๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidUserId_empty() { - // given - String userId = ""; - Long productId = 100L; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - - @Test - @DisplayName("productId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidProductId_null() { - // given - String userId = "user-001"; - Long productId = null; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - - @Test - @DisplayName("productId๊ฐ€ 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") - void createLike_invalidProductId_zeroOrNegative() { - // given - String userId = "user-001"; - Long productId = -1L; - - // when & then - assertThrows(CoreException.class, () -> Like.create(userId, productId)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java deleted file mode 100644 index 149e71540..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.application.order.CreateOrderCommand; -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; -import com.loopers.application.order.OrderItemCommand; -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderServiceIntegrationTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -@SpringBootTest -public class OrderServiceIntegrationTest { - - @Autowired - private OrderFacade orderFacade; - - @Autowired - private ProductRepository productRepository; - - @Autowired - private PointRepository pointRepository; - - @Autowired - private OrderRepository orderRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") - class OrderCreateSuccess { - - @Test - @Transactional - void createOrder_success() { - - // given - Product p1 = productRepository.save(Product.create(1L, "์•„๋ฉ”๋ฆฌ์นด๋…ธ", 3000L, 100L)); - Product p2 = productRepository.save(Product.create(1L, "๋ผ๋–ผ", 4000L, 200L)); - - pointRepository.save(Point.create("user1", 20000L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of( - new OrderItemCommand(p1.getId(), 2L), // 6000์› - new OrderItemCommand(p2.getId(), 1L) // 4000์› - ) - ); - - // when - OrderInfo info = orderFacade.createOrder(command); - - // then - Order saved = orderRepository.findById(info.orderId()).orElseThrow(); - - assertThat(saved.getStatus()).isEqualTo(OrderStatus.COMPLETE); - assertThat(saved.getTotalAmount()).isEqualTo(10000L); - assertThat(saved.getOrderItems()).hasSize(2); - - // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ - Product updated1 = productRepository.findById(p1.getId()).get(); - Product updated2 = productRepository.findById(p2.getId()).get(); - assertThat(updated1.getStock()).isEqualTo(98); - assertThat(updated2.getStock()).isEqualTo(199); - - // ํฌ์ธํŠธ ๊ฐ์†Œ ํ™•์ธ - Point point = pointRepository.findByUserId("user1").get(); - assertThat(point.getBalance()).isEqualTo(10000L); // 20000 - 10000 - - } - } - - @Nested - @DisplayName("์ฃผ๋ฌธ ์‹คํŒจ ์ผ€์ด์Šค") - class OrderCreateFail { - - @Test - @Transactional - @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") - void insufficientStock_fail() { - Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 1L)); - pointRepository.save(Point.create("user1", 5000L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(item.getId(), 5L)) - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .isInstanceOf(RuntimeException.class); // ๋„ˆ์˜ ๋„๋ฉ”์ธ ์˜ˆ์™ธ ํƒ€์ž… ๋งž์ถฐ๋„ ๋จ - } - - @Test - @Transactional - @DisplayName("ํฌ์ธํŠธ ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") - void insufficientPoint_fail() { - Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); - pointRepository.save(Point.create("user1", 2000L)); // ๋ถ€์กฑ - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(item.getId(), 5L)) // ์ด 5000์› - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .hasMessageContaining("ํฌ์ธํŠธ"); // ๋ฉ”์‹œ์ง€ ๋งž์ถ”๋ฉด ๋” ์ •ํ™•ํ•˜๊ฒŒ ๊ฐ€๋Šฅ - } - - @Test - @Transactional - @DisplayName("์—†๋Š” ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ ์‹คํŒจ") - void noProduct_fail() { - pointRepository.save(Point.create("user1", 10000L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(999L, 1L)) - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .isInstanceOf(RuntimeException.class); - } - - @Test - @Transactional - @DisplayName("์œ ์ € ํฌ์ธํŠธ ์ •๋ณด ์—†์œผ๋ฉด ์‹คํŒจ") - void noUserPoint_fail() { - Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); - - CreateOrderCommand command = new CreateOrderCommand( - "user1", - List.of(new OrderItemCommand(item.getId(), 1L)) - ); - - assertThatThrownBy(() -> orderFacade.createOrder(command)) - .isInstanceOf(RuntimeException.class); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java deleted file mode 100644 index 60ed16ecc..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * packageName : com.loopers.domain.order - * fileName : OrderTest - * author : byeonsungmun - * date : 2025. 11. 14. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class OrderTest { - - @Nested - @DisplayName("Order ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") - class CreateOrderTest { - - @Test - @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") - void createOrderSuccess() { - // when - Order order = Order.create("user123"); - - // then - assertThat(order.getUserId()).isEqualTo("user123"); - assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); - assertThat(order.getTotalAmount()).isEqualTo(0L); - assertThat(order.getCreatedAt()).isNotNull(); - assertThat(order.getOrderItems()).isEmpty(); - } - - @Test - @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createOrderFailUserIdNull() { - assertThatThrownBy(() -> Order.create(null)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); - } - - @Test - @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createOrderFailUserIdBlank() { - assertThatThrownBy(() -> Order.create("")) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); - } - } - - @Nested - @DisplayName("Order ์ƒํƒœ ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") - class UpdateStatusTest { - - @Test - @DisplayName("์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") - void updateStatusSuccess() { - // given - Order order = Order.create("user123"); - - // when - order.updateStatus(OrderStatus.COMPLETE); - - // then - assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETE); - } - } - - @Nested - @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") - class UpdateAmountTest { - - @Test - @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") - void updateTotalAmountSuccess() { - // given - Order order = Order.create("user123"); - - // when - order.updateTotalAmount(5000L); - - // then - assertThat(order.getTotalAmount()).isEqualTo(5000L); - } - } - - @Nested - @DisplayName("OrderItem ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ") - class AddOrderItemTest { - - @Test - @DisplayName("OrderItem ์ถ”๊ฐ€ ์„ฑ๊ณต") - void addOrderItemSuccess() { - // given - Order order = Order.create("user123"); - - OrderItem item = OrderItem.create( - 1L, - "์ƒํ’ˆ๋ช…", - 2L, - 1000L - ); - - // when - order.addOrderItem(item); - item.setOrder(order); - - // then - assertThat(order.getOrderItems()).hasSize(1); - assertThat(order.getOrderItems().getFirst().getProductName()).isEqualTo("์ƒํ’ˆ๋ช…"); - assertThat(item.getOrder()).isEqualTo(order); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java deleted file mode 100644 index b623bc9c7..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class PointServiceIntegrationTest { - - @Autowired - private PointRepository pointRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private PointService pointService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class PointUser { - - @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnPointInfo_whenValidIdIsProvided() { - //given - String id = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(Point.create(id, 0L)); - - //when - Point result = pointService.findPointByUserId(id); - - //then - assertThat(result.getUserId()).isEqualTo(id); - assertThat(result.getBalance()).isEqualTo(0L); - } - - @DisplayName("ํšŒ์›์ด ์กด์žฌ ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnNull_whenInvalidUserIdIsProvided() { - //given - String id = "yh45g"; - - //when - Point point = pointService.findPointByUserId(id); - - //then - assertThat(point).isNull(); - } - } - - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class Charge { - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsChargeAmountFailException_whenUserIDIsNotProvided() { - //given - String id = "yh45g"; - - //when - CoreException exception = assertThrows(CoreException.class, () -> pointService.chargePoint(id, 1000L)); - - //then - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - - @Test - @DisplayName("ํšŒ์›์ด ์กด์žฌํ•˜๋ฉด ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") - void chargeSuccess() { - // given - String userId = "user2"; - userRepository.save(new User(userId, "yh45g@loopers.com", "1994-12-05", "MALE")); - pointRepository.save(Point.create(userId, 1000L)); - - // when - Point updated = pointService.chargePoint(userId, 500L); - - // then - assertThat(updated.getBalance()).isEqualTo(1500L); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java deleted file mode 100644 index f33fb2821..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; - -class PointTest { - - @Nested - @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") - class CreatePointTest { - - @Test - @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ์„ฑ๊ณต") - void createPointSuccess() { - // when - Point point = Point.create("user123", 100L); - - // then - assertThat(point.getUserId()).isEqualTo("user123"); - assertThat(point.getBalance()).isEqualTo(100L); - } - - @Test - @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createPointFailUserIdNull() { - assertThatThrownBy(() -> Point.create(null, 100L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - @Test - @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") - void createPointFailUserIdBlank() { - assertThatThrownBy(() -> Point.create("", 100L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - } - - @Nested - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ…Œ์ŠคํŠธ") - class ChargePointTest { - - @Test - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") - void chargeSuccess() { - // given - Point point = Point.create("user123", 100L); - - // when - point.charge(50L); - - // then - assertThat(point.getBalance()).isEqualTo(150L); - } - - @Test - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") - void chargeFailZeroOrNegative() { - Point point = Point.create("user123", 100L); - - assertThatThrownBy(() -> point.charge(0L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์ถฉ์ „"); - - assertThatThrownBy(() -> point.charge(-10L)) - .isInstanceOf(CoreException.class); - } - } - - @Nested - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ํ…Œ์ŠคํŠธ") - class UsePointTest { - - @Test - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์„ฑ๊ณต") - void useSuccess() { - // given - Point point = Point.create("user123", 100L); - - // when - point.use(40L); - - // then - assertThat(point.getBalance()).isEqualTo(60L); - } - - @Test - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") - void useFailZeroOrNegative() { - Point point = Point.create("user123", 100L); - - assertThatThrownBy(() -> point.use(0L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - - assertThatThrownBy(() -> point.use(-10L)) - .isInstanceOf(CoreException.class); - } - - @Test - @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - ์ž”์•ก ๋ถ€์กฑ") - void useFailNotEnough() { - Point point = Point.create("user123", 50L); - - assertThatThrownBy(() -> point.use(100L)) - .isInstanceOf(CoreException.class) - .hasMessageContaining("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑ"); - } - } - -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java deleted file mode 100644 index 8ad61a194..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductServiceIntegrationTest - * author : byeonsungmun - * date : 2025. 11. 13. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ - -@SpringBootTest -public class ProductServiceIntegrationTest { - - @Autowired - private ProductService productService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @Nested - @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ํ…Œ์ŠคํŠธ") - class ProductListTests { - - Product product; - - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java deleted file mode 100644 index c2c6fdd9b..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -/** - * packageName : com.loopers.domain.product - * fileName : ProductTest - * author : byeonsungmun - * date : 2025. 11. 10. - * description : - * =========================================== - * DATE AUTHOR NOTE - * ------------------------------------------- - * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ - */ -class ProductTest { - @DisplayName("Product ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ ํ…Œ์ŠคํŠธ") - @Nested - class LikeCountChange { - - @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค.") - @Test - void increaseLikeCount_incrementsLikeCount() { - // given - Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - // when - product.increaseLikeCount(); - - // then - assertEquals(1L, product.getLikeCount()); - } - - @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ๊ฐ์†Œ์‹œํ‚จ๋‹ค. 0 ๋ฏธ๋งŒ์œผ๋กœ๋Š” ๊ฐ์†Œํ•˜์ง€ ์•Š๋Š”๋‹ค.") - @Test - void decreaseLikeCount_decrementsLikeCountButNotBelowZero() { - // given - Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 1L); - - // when - product.decreaseLikeCount(); - - // then - assertEquals(0L, product.getLikeCount()); - - // when decrease again - product.decreaseLikeCount(); - - // then likeCount should not go below 0 - assertEquals(0L, product.getLikeCount()); - } - } - - @DisplayName("Product ์žฌ๊ณ  ์ฐจ๊ฐ ํ…Œ์ŠคํŠธ") - @Nested - class Stock { - - @DisplayName("์žฌ๊ณ ๋ฅผ ์ •์ƒ ์ฐจ๊ฐํ•œ๋‹ค.") - @Test - void decreaseStock_successfullyDecreasesStock() { - // given - Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - // when - product.decreaseStock(3L); - - // then - assertEquals(7, product.getStock()); - } - - @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void decreaseStock_withInvalidQuantity_throwsException() { - Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - assertThrows(CoreException.class, () -> product.decreaseStock(0L)); - assertThrows(CoreException.class, () -> product.decreaseStock(-1L)); - } - - @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ํฐ ์ˆ˜๋Ÿ‰ ์ฐจ๊ฐ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ") - @Test - void decreaseStock_withInsufficientStock_throwsException() { - Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); - - assertThrows(CoreException.class, () -> product.decreaseStock(11L)); - } - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java deleted file mode 100644 index 71091883f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; - -@SpringBootTest -class UserServiceIntegrationTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserService userService; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("ํšŒ์› ๊ฐ€์ž… ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class UserRegister { - - @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") - @Test - void save_whenUserRegister() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String brith = "1994-12-05"; - String gender = "Male"; - - UserRepository userRepositorySpy = spy(userRepository); - UserService userServiceSpy = new UserService(userRepositorySpy); - - //when - userServiceSpy.register(userId, email, brith, gender); - - //then - verify(userRepositorySpy).save(any(User.class)); - } - - @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenDuplicateUserId() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String brith = "1994-12-05"; - String gender = "Male"; - - //when - userService.register(userId, email, brith, gender); - - //then - Assertions.assertThrows(CoreException.class, () - -> userService.register(userId, email, brith, gender)); - } - } - - @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") - @Nested - class Get { - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsUser_whenValidIdIsProvided() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String brith = "1994-12-05"; - String gender = "Male"; - - //when - userService.register(userId, email, brith, gender); - User user = userService.findUserByUserId(userId); - - //then - assertAll( - () -> assertThat(user.getUserId()).isEqualTo(userId), - () -> assertThat(user.getEmail()).isEqualTo(email), - () -> assertThat(user.getBirth()).isEqualTo(brith), - () -> assertThat(user.getGender()).isEqualTo(gender) - ); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnNull_whenInvalidUserIdIsProvided() { - //given - String userId = "yh45g"; - - //when - User user = userService.findUserByUserId(userId); - - //then - assertThat(user).isNull(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java deleted file mode 100644 index 7d74fdfe2..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -class UserTest { - @DisplayName("User ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") - @Nested - class Create { - @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidUserIdFormat() { - // given - String invalidUserId = "invalid_id_123"; // 10์ž ์ดˆ๊ณผ + ํŠน์ˆ˜๋ฌธ์ž ํฌํ•จ - String email = "valid@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - // when & then - assertThrows(CoreException.class, () -> new User(invalidUserId, email, birth, gender)); - } - - @DisplayName("์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidEmailFormat() { - // given - String userId = "yh45g"; - String invalidEmail = "invalid-email-format"; // '@' ์—†์Œ - String birth = "1994-12-05"; - String gender = "MALE"; - - // when & then - assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidBirthFormat() { - // given - String userId = "yh45g"; - String email = "valid@loopers.com"; - String invalidBirth = "19941205"; // ํ˜•์‹ ์˜ค๋ฅ˜: ํ•˜์ดํ”ˆ ์—†์Œ - String gender = "MALE"; - - // when & then - assertThrows(CoreException.class, () -> new User(userId, email, invalidBirth, gender)); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java new file mode 100644 index 000000000..1bb3dba65 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java @@ -0,0 +1,114 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.example.ExampleModel; +import com.loopers.infrastructure.example.ExampleJpaRepository; +import com.loopers.interfaces.api.example.ExampleV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ExampleV1ApiE2ETest { + + private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; + + private final TestRestTemplate testRestTemplate; + private final ExampleJpaRepository exampleJpaRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ExampleV1ApiE2ETest( + TestRestTemplate testRestTemplate, + ExampleJpaRepository exampleJpaRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.exampleJpaRepository = exampleJpaRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/examples/{id}") + @Nested + class Get { + @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsExampleInfo_whenValidIdIsProvided() { + // arrange + ExampleModel exampleModel = exampleJpaRepository.save( + new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") + ); + String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), + () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) + ); + } + + @DisplayName("์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID ๋กœ ์š”์ฒญํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenIdIsNotProvided() { + // arrange + String requestUrl = "/api/v1/examples/๋‚˜๋‚˜"; + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, 404 NOT_FOUND ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsException_whenInvalidIdIsProvided() { + // arrange + Long invalidId = -1L; + String requestUrl = ENDPOINT_GET.apply(invalidId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java deleted file mode 100644 index 7d7a2c18c..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class PointV1ControllerTest { - - private static final String GET_USER_POINT_ENDPOINT = "/api/v1/points"; - private static final String POST_USER_POINT_ENDPOINT = "/api/v1/points/charge"; - - @Autowired - private PointRepository pointRepository; - - @Autowired - private UserRepository userRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @Autowired - private TestRestTemplate testRestTemplate; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/points") - @Nested - class UserPoint { - - @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnPoint_whenValidUserIdIsProvided() { - //given - String id = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - Long amount = 1000L; - - userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(Point.create(id, amount)); - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(id), - () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) - ); - } - - @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNull_whenUserIdExists() { - //given - String id = "yh45g"; - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getBody().data()).isNull() - ); - } - } - - @DisplayName("POST /api/v1/points/charge") - @Nested - class Charge { - - @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsTotalPoint_whenChargeUserPoint() { - //given - String id = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - userRepository.save(new User(id, email, birth, gender)); - pointRepository.save(Point.create(id, 0L)); - - PointV1Dto.ChargePointRequest request = new PointV1Dto.ChargePointRequest(id, 1000L); - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(id), - () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidUserIdIsProvided() { - //given - String id = "yh45g"; - - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", id); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(null, headers), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java deleted file mode 100644 index defe2fcd5..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java +++ /dev/null @@ -1,148 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.domain.user.User; -import com.loopers.infrastructure.user.UserJpaRepository; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class UserV1ControllerTest { - - private static final String USER_REGISTER_ENDPOINT = "/api/v1/users/register"; - private static final Function GET_USER_ENDPOINT = id -> "/api/v1/users/" + id; - - @Autowired - private TestRestTemplate testRestTemplate; - - @Autowired - private UserJpaRepository userJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("POST /api/v1/users") - @Nested - class RegisterUser { - @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void registerUser_whenSuccessResponseUser() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), - () -> assertThat(response.getBody().data().email()).isEqualTo(email), - () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), - () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) - ); - } - @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") - @Test - void throwsBadRequest_whenGenderIsNotProvided() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = null; - - UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - } - - @DisplayName("GET /api/v1/users/{userId}") - @Nested - class GetUserById { - @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void getUserById_whenSuccessResponseUser() { - //given - String userId = "yh45g"; - String email = "yh45g@loopers.com"; - String birth = "1994-12-05"; - String gender = "MALE"; - - userJpaRepository.save(new User(userId, email, birth, gender)); - - String requestUrl = GET_USER_ENDPOINT.apply(userId); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), - () -> assertThat(response.getBody().data().email()).isEqualTo(email), - () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), - () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidUserIdIsProvided() { - //given - String userId = "notUserId"; - String requestUrl = GET_USER_ENDPOINT.apply(userId); - - //when - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - //then - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/docs/1round/1round.md b/docs/1round/1round.md deleted file mode 100644 index 106d6c809..000000000 --- a/docs/1round/1round.md +++ /dev/null @@ -1,67 +0,0 @@ -## ๐Ÿงช Implementation Quest - -> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. -> - -### ํšŒ์› ๊ฐ€์ž… - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [x] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [x] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. -- [x] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [x] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) -- [x] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [x] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [x] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ๋‚ด ์ •๋ณด ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [x] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [x] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์กฐํšŒ - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. -- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [x] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [x] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -### ํฌ์ธํŠธ ์ถฉ์ „ - -**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** - -- [X] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. - -**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** - -- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. - -**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** - -- [X] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. -- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - -## โœ… Checklist - -- [X] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ -- [X] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ -- [X] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ \ No newline at end of file diff --git a/docs/2round/01-requirements.md b/docs/2round/01-requirements.md deleted file mode 100644 index 3296c21c6..000000000 --- a/docs/2round/01-requirements.md +++ /dev/null @@ -1,104 +0,0 @@ -# ์œ ์ €-์‹œ๋‚˜๋ฆฌ์˜ค - -## ์ƒํ’ˆ ๋ชฉ๋ก -1. ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋“  ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ๋ณผ์ˆ˜ ์žˆ๋‹ค. -2. ํŒ๋งค์ค‘์ธ ์ƒํ’ˆ์— ๋Œ€ํ•œ ํŒ๋งค๋ช…, ํŒ๋งค๊ธˆ์•ก, ํŒ๋งค๋ธŒ๋žœ๋“œ, ์ด๋ฏธ์ง€, ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. -3. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ณ„๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜ ์žˆ๋‹ค. -4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์ƒํ’ˆ์—๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ์ˆ˜์žˆ๋‹ค. -5. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. -6. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. - --[๊ธฐ๋Šฅ] -1. ์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -2. ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก -4. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) -5. ํŽ˜์ด์ง• - --[์ œ์•ฝ] -1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค -2. ์ƒํ’ˆ์ด ์—†์„๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. - ---- -## ์ƒํ’ˆ ์ƒ์„ธ -1. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŒ๋งค์ค‘ ์ƒํ’ˆ(ํŒ๋งค๋ช…,ํŒ๋งค๊ธˆ์•ก,ํŒ๋งค๋ธŒ๋žœ๋“œ,์ด๋ฏธ์ง€,์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. - -[๊ธฐ๋Šฅ] -1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ๋ฒˆํ˜ธ๋กœ ์กฐํšŒ -2. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก / ์ทจ์†Œ - -[์ œ์•ฝ] -1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค -2. ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. - ---- -## ์ข‹์•„์š” -1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ ์ˆ˜ ์žˆ๋‹ค. -2. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œ ํ•  ์ˆ˜ ์žˆ๋‹ค. -2. ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด ๋ชฉ๋ก์„ ๋ณผ์ˆ˜์žˆ๋‹ค. - -[๊ธฐ๋Šฅ] -1. ์ข‹์•„์š” ๋ˆ„๋ฅธ ์ƒํ’ˆ์—๋Œ€ํ•ด ๋ชฉ๋ก ์กฐํšŒ -2. ์‚ฌ์šฉ๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•œ ๋“ฑ๋ก/์ทจ์†Œ, ๋‹จ ๋“ฑ๋ก/ํ•ด์ œ (๋ฉฑ๋“ฑ์„ฑ) - -[์ œ์•ฝ] -1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค -2. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ์ฒ˜์Œ ๋“ฑ๋ก ํ• ๋•Œ๋Š” 201_Created ์ œ๊ณตํ•œ๋‹ค -3. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ํ•œ๋ฒˆ๋” ๋“ฑ๋ก ํ• ๋•Œ๋Š” 200_OK ์ œ๊ณตํ•œ๋‹ค -4. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก ๋œ ์ƒํƒœ์—์„œ ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค -5. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๊ฐ€ ๋œ ์ƒํƒœ์—์„œ ํ•œ๋ฒˆ๋” ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค ---- -## ๋ธŒ๋žœ๋“œ -1. ์‚ฌ์šฉ์ž๋Š” ๋ชจ๋“  ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. -2. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๋ธŒ๋žœ๋“œ์— ๋Œ€ํ•œ ์ƒํ’ˆ๋งŒ ๋ณผ์ˆ˜์žˆ๋‹ค. -3. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๊ธฐ์ค€์œผ๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ) -4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. - [๊ธฐ๋Šฅ] -1. ๋ชจ๋“  ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ -2. ํŠน์ • ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ -3. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) -4. ํŽ˜์ด์ง• - [์ œ์•ฝ] -1. ๋ธŒ๋žœ๋“œ๊ฐ€ ์—†์„์‹œ 404_NOTFOUND๋ฅผ ์ œ๊ณตํ•œ๋‹ค ---- -## ์ฃผ๋ฌธ -1. ์‚ฌ์šฉ์ž๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก์—์„œ ์›ํ•˜๋Š” ์ƒํ’ˆ์„ ์„ ํƒํ•˜์—ฌ ์ฃผ๋ฌธํ•  ์ˆ˜ ์žˆ๋‹ค. -2. ํ•œ๊ฐœ์˜ ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ๋‹ค. -3. ์‚ฌ์šฉ์ž๊ฐ€ ์ฃผ๋ฌธ ๋‚ด์—ญ์„ ์กฐํšŒํ•ด ์–ด๋–ค ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. -4. ์‚ฌ์šฉ์ž๋Š” ๊ฒฐ์ œ ์ „์ด๋ผ๋ฉด ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•  ์ˆ˜ ์žˆ๋‹ค. -5. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธ ์ƒ์„ธ ํ™”๋ฉด์—์„œ ์ƒํ’ˆ ์ •๋ณด, ์ˆ˜๋Ÿ‰, ๊ฒฐ์ œ ๊ธˆ์•ก, ์ƒํƒœ ๋“ฑ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. - [๊ธฐ๋Šฅ] -1. ์ฃผ๋ฌธ ์ƒ์„ฑ -2. ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ -3. ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ -4. ์ฃผ๋ฌธ ์ทจ์†Œ -5. ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์ƒํƒœ๊ด€๋ฆฌ - [์ œ์•ฝ] -1. ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ ์žฌ๊ณ  ํ™•์ธ ๋ฐ ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ -2. ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ ์‹œ ์ฃผ๋ฌธ ๋ถˆ๊ฐ€ -3. ๋™์ผํ•œ ์ฃผ๋ฌธ ์š”์ฒญ์ด ์ค‘๋ณต์œผ๋กœ ๋“ค์–ด์™€๋„ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ ---- -## ๊ฒฐ์ œ -1. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธํ•œ ์ƒํ’ˆ์— ๋Œ€ํ•ด ํฌ์ธํŠธ๋กœ ๊ฒฐ์ œํ•  ์ˆ˜ ์žˆ๋‹ค. -2. ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ๊ฒฐ์ œ ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค. -3. ๊ฒฐ์ œ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๊ฒฐ์ œ ์‹คํŒจ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ํฌ์ธํŠธ์™€ ์žฌ๊ณ ๋Š” ๋ณต๊ตฌ๋œ๋‹ค. -4. ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„์—๋Š” ์ฃผ๋ฌธ ์ทจ์†Œ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค. - [๊ธฐ๋Šฅ] -1. ๊ฒฐ์ œ์š”์ฒญ -2. ๊ฒฐ์ œ ๊ฒฐ๊ณผ ๋ฐ˜์˜ -3. ๊ฒฐ์ œ ์‹คํŒจ ์ฒ˜๋ฆฌ -4. ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ - [์ œ์•ฝ] -1. ๋™์ผ ์ฃผ๋ฌธ์— ๋Œ€ํ•ด ์ค‘๋ณต ๊ฒฐ์ œ ์š”์ฒญ ์‹œ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ -2. ํฌ์ธํŠธ ์ฐจ๊ฐ์‹คํŒจ ์‹œ ๋ณต๊ตฌ -3. ์™ธ๋ถ€๊ฒฐ์ œ ์‹œ์Šคํ…œ ๊ฒฐ์ œ ์‹œ์Šคํ…œ ์ฒ˜๋ฆฌ ์‹คํŒจ์‹œ ์˜ˆ์™ธ์ฒ˜๋ฆฌ - ----- -## Ubiquitous -| ํ•œ๊ตญ์–ด | ์˜์–ด | -|--------|------| -| ์‚ฌ์šฉ์ž | User | -| ํฌ์ธํŠธ | Point | -| ์ƒํ’ˆ | Product | -| ๋ธŒ๋žœ๋“œ | Brand | -| ์ข‹์•„์š” | Like | -| ์ฃผ๋ฌธ | Order | -| ์žฌ๊ณ  | Stock | -| ๊ฐ€๊ฒฉ | Price | -| ๊ฒฐ์ œ | Payment | \ No newline at end of file diff --git a/docs/2round/02-sequence-diagrams.md b/docs/2round/02-sequence-diagrams.md deleted file mode 100644 index 5264a4dc0..000000000 --- a/docs/2round/02-sequence-diagrams.md +++ /dev/null @@ -1,164 +0,0 @@ -# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - -### 1. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant ProductController - participant ProductService - participant ProductRepository - participant BrandRepository - participant LikeRepository - - User->>ProductController: GET /api/v1/products - ProductController->>ProductService: getProductList - ProductService->>ProductRepository: findAllWithPaging - ProductService->>BrandRepository: findBrandInfoForProducts() - ProductService->>LikeRepository: countLikesForProducts() - ProductRepository-->>ProductService: productList - ProductService-->>ProductController: productListResponse - ProductController-->>User: 200 OK (์ƒํ’ˆ ๋ชฉ๋ก + ๋ธŒ๋žœ๋“œ + ์ข‹์•„์š” ์ˆ˜) -``` ---- -### 2. ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant ProductController - participant ProductService - participant ProductRepository - participant BrandRepository - participant LikeRepository - - User->>ProductController: GET /api/v1/products/{productId} - ProductController->>ProductService: getProductDetail(productId, userId) - ProductService->>ProductRepository: findById(productId) - ProductService->>BrandRepository: findBrandInfo(brandId) - ProductService->>LikeRepository: existsByUserIdAndProductId(userId, productId) - ProductRepository-->>ProductService: productDetail - ProductService-->>ProductController: productDetailResponse - ProductController-->>User: 200 OK (์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด) -``` ---- -### 3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ -```mermaid -sequenceDiagram - participant User - participant LikeController - participant LikeService - participant LikeRepository - - User->>LikeController: POST /api/v1/like/products/{productId} - LikeController->>LikeService: toggleLike(userId, productId) - LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) - alt ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ - LikeService->>LikeRepository: save(userId, productId) - LikeService-->>LikeController: 201 Created - else ์ด๋ฏธ ์ข‹์•„์š” ๋˜์–ด์žˆ์Œ - LikeService->>LikeRepository: delete(userId, productId) - LikeService-->>LikeController: 204 No Content - end - LikeController-->>User: ์‘๋‹ต (์ƒํƒœ์ฝ”๋“œ์— ๋”ฐ๋ผ ๋‹ค๋ฆ„) -``` ---- - -### 4. ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant BrandController - participant BrandService - participant ProductRepository - participant BrandRepository - - User->>BrandController: GET /api/v1/brands/{brandId}/products - BrandController->>BrandService: getProductsByBrand(brandId, sort, page) - BrandService->>BrandRepository: findById(brandId) - BrandService->>ProductRepository: findByBrandId(brandId, sort, page) - BrandRepository-->>BrandService: brandInfo - ProductRepository-->>BrandService: productList - BrandService-->>BrandController: productListResponse - BrandController-->>User: 200 OK (๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก) -``` ---- -### 5. ์ฃผ๋ฌธ ์ƒ์„ฑ -```mermaid -sequenceDiagram - participant User - participant OrderController - participant OrderService - participant ProductReader - participant StockService - participant PointService - participant OrderRepository - - User->>OrderController: POST /api/v1/orders (items[]) - OrderController->>OrderService: createOrder(userId, items) - OrderService->>ProductReader: getProductsByIds(productIds) - loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด - OrderService->>StockService: checkAndDecreaseStock(productId, quantity) - end - OrderService->>PointService: deductPoint(userId, totalPrice) - alt ์žฌ๊ณ  ๋˜๋Š” ํฌ์ธํŠธ ๋ถ€์กฑ - OrderService-->>OrderController: throw Exception - OrderController-->>User: 400 Bad Request - else ์ •์ƒ - OrderService->>OrderRepository: save(order, orderItems) - OrderService-->>OrderController: OrderResponse - OrderController-->>User: 201 Created (์ฃผ๋ฌธ ์™„๋ฃŒ) - end -``` ---- -### 6. ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ ์ƒ์„ธ ์กฐํšŒ -```mermaid -sequenceDiagram - participant User - participant OrderController - participant OrderService - participant OrderRepository - participant ProductRepository - - User->>OrderController: GET /api/v1/orders - OrderController->>OrderService: getOrderList(userId) - OrderService->>OrderRepository: findByUserId(userId) - OrderRepository-->>OrderService: orderList - OrderService-->>OrderController: orderListResponse - OrderController-->>User: 200 OK (์ฃผ๋ฌธ ๋ชฉ๋ก) - - User->>OrderController: GET /api/v1/orders/{orderId} - OrderController->>OrderService: getOrderDetail(orderId, userId) - OrderService->>OrderRepository: findById(orderId) - OrderService->>ProductRepository: findProductsInOrder(orderId) - OrderRepository-->>OrderService: orderDetail - OrderService-->>OrderController: orderDetailResponse - OrderController-->>User: 200 OK (์ฃผ๋ฌธ ์ƒ์„ธ) -``` ---- -### 7. ๊ฒฐ์ œ ์ฒ˜๋ฆฌ -```mermaid -sequenceDiagram - participant User - participant PaymentController - participant PaymentService - participant PaymentGateway - participant OrderRepository - participant PointService - participant StockService - - User->>PaymentController: POST /api/v1/payments (orderId) - PaymentController->>PaymentService: processPayment(orderId, userId) - PaymentService->>OrderRepository: findById(orderId) - PaymentService->>PaymentGateway: requestPayment(orderId, amount) - alt ๊ฒฐ์ œ ์„ฑ๊ณต - PaymentGateway-->>PaymentService: SUCCESS - PaymentService->>OrderRepository: updateStatus(orderId, PAID) - PaymentService-->>PaymentController: successResponse - PaymentController-->>User: 200 OK (๊ฒฐ์ œ ์™„๋ฃŒ) - else ๊ฒฐ์ œ ์‹คํŒจ - PaymentGateway-->>PaymentService: FAILED - PaymentService->>PointService: rollbackPoint(userId, amount) - PaymentService->>StockService: restoreStock(orderId) - PaymentService->>OrderRepository: updateStatus(orderId, FAILED) - PaymentController-->>User: 500 Internal Server Error (๊ฒฐ์ œ ์‹คํŒจ) - end -``` diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md deleted file mode 100644 index 8d39cfd0a..000000000 --- a/docs/2round/03-class-diagram.md +++ /dev/null @@ -1,78 +0,0 @@ -# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ - -```mermaid -classDiagram -direction TB - -class User { - Long id - String userId - String name - String email - String gender -} - -class Point { - Long id - String userId - Long balance -} - -class Brand { - Long id - String name -} - -class Product { - Long id - Long brandId - String name - Long price - Long likeCount; - Long stock -} - -class Like { - Long id - String userId - Long productId - LocalDateTime createdAt -} - -class Order { - Long id - String userId - Long totalPrice - OrderStatus status - LocalDateTime createdAt - List orderItems -} - -class OrderItem { - Long id - Order order - Long productId - String productName - Long quantity - Long price -} - -class Payment { - Long id - Long orderId - String status - String paymentRequestId - LocalDateTime createdAt -} - -%% ๊ด€๊ณ„ ์„ค์ • -User --> Point -Brand --> Product -Product --> Like -User --> Like -User --> Order -Order --> OrderItem -Order --> Payment -OrderItem --> Product - -``` \ No newline at end of file diff --git a/docs/2round/04-erd.md b/docs/2round/04-erd.md deleted file mode 100644 index 6389b2202..000000000 --- a/docs/2round/04-erd.md +++ /dev/null @@ -1,74 +0,0 @@ -# erd - -```mermaid -erDiagram - USER { - bigint id PK - varchar user_id - varchar name - varchar email - varchar gender - } - - POINT { - bigint id PK - varchar user_id FK - bigint balance - } - - BRAND { - bigint id PK - varchar name - } - - PRODUCT { - bigint id PK - bigint brand_id FK - varchar name - bigint price - bigint like_count - bigint stock - } - - LIKE { - bigint id PK - varchar user_id FK - bigint product_id FK - datetime created_at - } - - ORDERS { - bigint id PK - varchar user_id FK - bigint total_amount - varchar status - datetime created_at - } - - ORDER_ITEM { - bigint id PK - bigint order_id FK - bigint product_id FK - varchar product_name - bigint quantity - bigint price - } - - PAYMENT { - bigint id PK - bigint order_id FK - varchar status - varchar payment_request_id - datetime created_at - } - - %% ๊ด€๊ณ„ (cardinality) - USER ||--|| POINT : "1:1" - BRAND ||--o{ PRODUCT : "1:N" - PRODUCT ||--o{ LIKE : "1:N" - USER ||--o{ LIKE : "1:N" - USER ||--o{ ORDERS : "1:N" - ORDERS ||--o{ ORDER_ITEM : "1:N" - ORDER_ITEM }o--|| PRODUCT : "N:1" - ORDERS ||--|| PAYMENT : "1:1" -``` \ No newline at end of file diff --git a/docs/2round/2round.md b/docs/2round/2round.md deleted file mode 100644 index 84fdc982c..000000000 --- a/docs/2round/2round.md +++ /dev/null @@ -1,37 +0,0 @@ -## โœ๏ธ Design Quest - -> **์ด์ปค๋จธ์Šค ๋„๋ฉ”์ธ(์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๋“ฑ)์— ๋Œ€ํ•œ ์„ค๊ณ„**๋ฅผ ์™„๋ฃŒํ•˜๊ณ , ๋‹ค์Œ ์ฃผ๋ถ€ํ„ฐ ๊ฐœ๋ฐœ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€์˜ ์„ค๊ณ„ ๋ฌธ์„œ๋ฅผ ์ •๋ฆฌํ•˜์—ฌ PR๋กœ ์ œ์ถœํ•ฉ๋‹ˆ๋‹ค. -> - -### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด - -- **์„ค๊ณ„ ๋ฒ”์œ„** - - ์ƒํ’ˆ ๋ชฉ๋ก / ์ƒํ’ˆ ์ƒ์„ธ / ๋ธŒ๋žœ๋“œ ์กฐํšŒ - - ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ (๋ฉฑ๋“ฑ ๋™์ž‘) - - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ๊ฒฐ์ œ ํ๋ฆ„ (์žฌ๊ณ  ์ฐจ๊ฐ, ํฌ์ธํŠธ ์ฐจ๊ฐ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™) -- **์ œ์™ธ ๋„๋ฉ”์ธ** - - ํšŒ์›๊ฐ€์ž…, ํฌ์ธํŠธ ์ถฉ์ „ (1์ฃผ์ฐจ ๊ตฌํ˜„ ์™„๋ฃŒ ๊ธฐ์ค€) -- **์š”๊ตฌ์‚ฌํ•ญ ๊ธฐ๋ฐ˜** - - ๋ฃจํ”„ํŒฉ ์ด์ปค๋จธ์Šค ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฌธ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๋Šฅ/์ œ์•ฝ์‚ฌํ•ญ์„ ์„ค๊ณ„์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. -- **์ œ์ถœ ๋ฐฉ์‹** - 1. ์•„๋ž˜ ํŒŒ์ผ๋“ค์„ ํ”„๋กœ์ ํŠธ ๋‚ด `docs/week2/` ํด๋”์— `.md`๋กœ ์ €์žฅ - 2. Github PR๋กœ ์ œ์ถœ - - PR ์ œ๋ชฉ: `[2์ฃผ์ฐจ] ์„ค๊ณ„ ๋ฌธ์„œ ์ œ์ถœ - ํ™๊ธธ๋™` - - PR ๋ณธ๋ฌธ์— ๋ฆฌ๋ทฐ ํฌ์ธํŠธ ํฌํ•จ (์˜ˆ: ๊ณ ๋ฏผํ•œ ์ง€์  ๋“ฑ) - -### โœ… ์ œ์ถœ ํŒŒ์ผ ๋ชฉ๋ก (.docs/design ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด) - -| ํŒŒ์ผ๋ช… | ๋‚ด์šฉ | -| --- | --- | -| `01-requirements.md` | ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค ๊ธฐ๋ฐ˜ ๊ธฐ๋Šฅ ์ •์˜, ์š”๊ตฌ์‚ฌํ•ญ ๋ช…์„ธ | -| `02-sequence-diagrams.md` | ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ ์ตœ์†Œ 2๊ฐœ ์ด์ƒ (Mermaid ๊ธฐ๋ฐ˜ ์ž‘์„ฑ ๊ถŒ์žฅ) | -| `03-class-diagram.md` | ๋„๋ฉ”์ธ ๊ฐ์ฒด ์„ค๊ณ„ (ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ or ์„ค๋ช… ์ค‘์‹ฌ) | -| `04-erd.md` | ์ „์ฒด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ๋ฐ ๊ด€๊ณ„ ์ •๋ฆฌ (ERD Mermaid ์ž‘์„ฑ ๊ฐ€๋Šฅ) | - -## โœ… Checklist - -- [ ] ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ/์ข‹์•„์š”/์ฃผ๋ฌธ ๋„๋ฉ”์ธ์ด ๋ชจ๋‘ ํฌํ•จ๋˜์–ด ์žˆ๋Š”๊ฐ€? -- [ ] ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์ด ์œ ์ € ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ๋˜์–ด ์žˆ๋Š”๊ฐ€? -- [ ] ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์—์„œ ์ฑ…์ž„ ๊ฐ์ฒด๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š”๊ฐ€? -- [ ] ํด๋ž˜์Šค ๊ตฌ์กฐ๊ฐ€ ๋„๋ฉ”์ธ ์„ค๊ณ„๋ฅผ ์ž˜ ํ‘œํ˜„ํ•˜๊ณ  ์žˆ๋Š”๊ฐ€? -- [ ] ERD ์„ค๊ณ„ ์‹œ ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์„ ๊ณ ๋ คํ•˜์—ฌ ๊ตฌ์„ฑํ•˜์˜€๋Š”๊ฐ€? \ No newline at end of file diff --git a/docs/3round/3round.md b/docs/3round/3round.md deleted file mode 100644 index b9f333cca..000000000 --- a/docs/3round/3round.md +++ /dev/null @@ -1,60 +0,0 @@ -# ๐Ÿ“ Round 3 Quests - ---- - -## ๐Ÿ’ป Implementation Quest - -> *** ๋„๋ฉ”์ธ ๋ชจ๋ธ๋ง**์„ ํ†ตํ•ด Product, Brand, Like, Order ๊ธฐ๋Šฅ์˜ ํ•ต์‹ฌ ๊ฐœ๋…์„ **Entity, Value Object, Domain Service ๋“ฑ ์ ํ•ฉํ•œ** **์ฝ”๋“œ**๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. -* ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ + DIP ๋ฅผ ์ ์šฉํ•ด ์œ ์—ฐํ•˜๊ณ  ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. -* **Application Layer๋ฅผ ๊ฒฝ๋Ÿ‰ ์ˆ˜์ค€**์œผ๋กœ ๊ตฌ์„ฑํ•˜์—ฌ, ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ์‹ค์ œ ๊ตฌํ˜„ํ•ด๋ด…๋‹ˆ๋‹ค. -* **๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑ**ํ•˜์—ฌ ๋„๋ฉ”์ธ ๋กœ์ง์˜ ์ •ํ•ฉ์„ฑ๊ณผ ๊ทœ์น™์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. -> - -### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด - -- ์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ธฐ๋Šฅ์˜ **๋„๋ฉ”์ธ ๋ชจ๋ธ ๋ฐ ๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. -- ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ํ๋ฆ„์„ ์„ค๊ณ„ํ•˜๊ณ , ํ•„์š”ํ•œ ๋กœ์ง์„ **๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋กœ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค. -- Application Layer์—์„œ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. - (์˜ˆ: `ProductFacade.getProductDetail(productId)` โ†’ `Product + Brand + Like ์กฐํ•ฉ`) -- Repository Interface ์™€ ๊ตฌํ˜„์ฒด๋Š” ๋ถ„๋ฆฌํ•˜๊ณ , ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๊ณ ๋ คํ•œ ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค. -- ๋ชจ๋“  ํ•ต์‹ฌ ๋„๋ฉ”์ธ ๋กœ์ง์— ๋Œ€ํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ์˜ˆ์™ธ/๊ฒฝ๊ณ„ ์ผ€์ด์Šค๋„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. - -### ๐Ÿท Product / Brand ๋„๋ฉ”์ธ - -## โœ… Checklist - -- [x] ์ƒํ’ˆ ์ •๋ณด ๊ฐ์ฒด๋Š” ๋ธŒ๋žœ๋“œ ์ •๋ณด, ์ข‹์•„์š” ์ˆ˜๋ฅผ ํฌํ•จํ•œ๋‹ค. -- [x] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค -- [x] ์ƒํ’ˆ์€ ์žฌ๊ณ ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๊ณ , ์ฃผ๋ฌธ ์‹œ ์ฐจ๊ฐํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค -- [x] ์žฌ๊ณ ๋Š” ๊ฐ์†Œ๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ์Œ์ˆ˜ ๋ฐฉ์ง€๋Š” ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ๋œ๋‹ค - -### ๐Ÿ‘ Like ๋„๋ฉ”์ธ - -- [x] ์ข‹์•„์š”๋Š” ์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค -- [x] ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๊ฐ€ ๊ตฌํ˜„๋˜์—ˆ๋‹ค -- [x] ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์กฐํšŒ์—์„œ ํ•จ๊ป˜ ์ œ๊ณต๋œ๋‹ค -- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ/์ค‘๋ณต ๋ฐฉ์ง€ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค - -### ๐Ÿ›’ Order ๋„๋ฉ”์ธ - -- [x] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค -- [x] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ, ์œ ์ € ํฌ์ธํŠธ ์ฐจ๊ฐ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค -- [x] ์žฌ๊ณ  ๋ถ€์กฑ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋“ฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค -- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค - -### ๐Ÿงฉ ๋„๋ฉ”์ธ ์„œ๋น„์Šค - -- [x] ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ๋กœ์ง์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค -- [x] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ ๋„๋ฉ”์ธ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค -- [x] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค -- [x] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค - -### **๐Ÿงฑ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ & ์„ค๊ณ„** - -- [x] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค - - Application โ†’ **Domain** โ† Infrastructure -- [x] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค -- [x] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค -- [x] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค -- [x] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) -- [x] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค \ No newline at end of file From b84525a82190d877a7751ba156bca4492b240ede Mon Sep 17 00:00:00 2001 From: BOB <56067193+adminhelper@users.noreply.github.com> Date: Fri, 21 Nov 2025 16:58:59 +0900 Subject: [PATCH 063/164] =?UTF-8?q?Revert=20"Revert=20"[volume-3]=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=ED=98=84""?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0074ea918de0aee3995308e4ab57f0fba05428a5. --- .../application/example/ExampleFacade.java | 17 -- .../application/example/ExampleInfo.java | 13 -- .../loopers/application/like/LikeFacade.java | 32 ++++ .../application/order/CreateOrderCommand.java | 19 ++ .../application/order/OrderFacade.java | 81 +++++++++ .../loopers/application/order/OrderInfo.java | 42 +++++ .../application/order/OrderItemCommand.java | 17 ++ .../application/order/OrderItemInfo.java | 32 ++++ .../application/point/PointFacade.java | 28 +++ .../loopers/application/point/PointInfo.java | 13 ++ .../product/ProductDetailInfo.java | 32 ++++ .../application/product/ProductFacade.java | 47 +++++ .../application/product/ProductInfo.java | 33 ++++ .../loopers/application/user/UserFacade.java | 27 +++ .../loopers/application/user/UserInfo.java | 14 ++ .../java/com/loopers/domain/brand/Brand.java | 47 +++++ .../loopers/domain/brand/BrandRepository.java | 20 +++ .../loopers/domain/brand/BrandService.java | 35 ++++ .../loopers/domain/example/ExampleModel.java | 44 ----- .../domain/example/ExampleRepository.java | 7 - .../domain/example/ExampleService.java | 20 --- .../java/com/loopers/domain/like/Like.java | 63 +++++++ .../loopers/domain/like/LikeRepository.java | 25 +++ .../com/loopers/domain/like/LikeService.java | 49 +++++ .../java/com/loopers/domain/order/Order.java | 86 +++++++++ .../com/loopers/domain/order/OrderItem.java | 91 ++++++++++ .../loopers/domain/order/OrderRepository.java | 21 +++ .../loopers/domain/order/OrderService.java | 28 +++ .../com/loopers/domain/order/OrderStatus.java | 42 +++++ .../java/com/loopers/domain/point/Point.java | 56 ++++++ .../loopers/domain/point/PointRepository.java | 10 ++ .../loopers/domain/point/PointService.java | 43 +++++ .../com/loopers/domain/product/Product.java | 120 +++++++++++++ .../loopers/domain/product/ProductDetail.java | 45 +++++ .../domain/product/ProductDomainService.java | 41 +++++ .../domain/product/ProductRepository.java | 29 +++ .../domain/product/ProductService.java | 53 ++++++ .../java/com/loopers/domain/user/User.java | 82 +++++++++ .../loopers/domain/user/UserRepository.java | 10 ++ .../com/loopers/domain/user/UserService.java | 30 ++++ .../brand/BrandJpaRepository.java | 18 ++ .../brand/BrandRepositoryImpl.java | 36 ++++ .../example/ExampleJpaRepository.java | 6 - .../example/ExampleRepositoryImpl.java | 19 -- .../like/LikeJpaRepository.java | 23 +++ .../like/LikeRepositoryImpl.java | 46 +++++ .../order/OrderJpaRepository.java | 18 ++ .../order/OrderRepositoryImpl.java | 36 ++++ .../point/PointJpaRepository.java | 11 ++ .../point/PointRepositoryImpl.java | 25 +++ .../product/ProductJpaRepository.java | 19 ++ .../product/ProductRepositoryImpl.java | 59 ++++++ .../user/UserJpaRepository.java | 11 ++ .../user/UserRepositoryImpl.java | 26 +++ .../api/example/ExampleV1ApiSpec.java | 19 -- .../api/example/ExampleV1Controller.java | 28 --- .../interfaces/api/example/ExampleV1Dto.java | 15 -- .../interfaces/api/point/PointV1ApiSpec.java | 28 +++ .../api/point/PointV1Controller.java | 31 ++++ .../interfaces/api/point/PointV1Dto.java | 18 ++ .../interfaces/api/user/UserV1ApiSpec.java | 28 +++ .../interfaces/api/user/UserV1Controller.java | 31 ++++ .../interfaces/api/user/UserV1Dto.java | 24 +++ .../com/loopers/domain/brand/BrandTest.java | 42 +++++ .../domain/example/ExampleModelTest.java | 65 ------- .../ExampleServiceIntegrationTest.java | 72 -------- .../like/LikeServiceIntegrationTest.java | 155 ++++++++++++++++ .../com/loopers/domain/like/LikeTest.java | 91 ++++++++++ .../order/OrderServiceIntegrationTest.java | 170 ++++++++++++++++++ .../com/loopers/domain/order/OrderTest.java | 122 +++++++++++++ .../point/PointServiceIntegrationTest.java | 108 +++++++++++ .../com/loopers/domain/point/PointTest.java | 117 ++++++++++++ .../ProductServiceIntegrationTest.java | 43 +++++ .../loopers/domain/product/ProductTest.java | 95 ++++++++++ .../user/UserServiceIntegrationTest.java | 112 ++++++++++++ .../com/loopers/domain/user/UserTest.java | 53 ++++++ .../interfaces/api/ExampleV1ApiE2ETest.java | 114 ------------ .../api/point/PointV1ControllerTest.java | 156 ++++++++++++++++ .../api/user/UserV1ControllerTest.java | 148 +++++++++++++++ docs/1round/1round.md | 67 +++++++ docs/2round/01-requirements.md | 104 +++++++++++ docs/2round/02-sequence-diagrams.md | 164 +++++++++++++++++ docs/2round/03-class-diagram.md | 78 ++++++++ docs/2round/04-erd.md | 74 ++++++++ docs/2round/2round.md | 37 ++++ docs/3round/3round.md | 60 +++++++ 86 files changed, 3927 insertions(+), 439 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java create mode 100644 docs/1round/1round.md create mode 100644 docs/2round/01-requirements.md create mode 100644 docs/2round/02-sequence-diagrams.md create mode 100644 docs/2round/03-class-diagram.md create mode 100644 docs/2round/04-erd.md create mode 100644 docs/2round/2round.md create mode 100644 docs/3round/3round.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java deleted file mode 100644 index 552a9ad62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleFacade.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@RequiredArgsConstructor -@Component -public class ExampleFacade { - private final ExampleService exampleService; - - public ExampleInfo getExample(Long id) { - ExampleModel example = exampleService.getExample(id); - return ExampleInfo.from(example); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java deleted file mode 100644 index 877aba96c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/example/ExampleInfo.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.application.example; - -import com.loopers.domain.example.ExampleModel; - -public record ExampleInfo(Long id, String name, String description) { - public static ExampleInfo from(ExampleModel model) { - return new ExampleInfo( - model.getId(), - model.getName(), - model.getDescription() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..d9dd33205 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,32 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * packageName : com.loopers.application.like + * fileName : LikeFacade + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class LikeFacade { + + private final LikeService likeService; + + public void createLike(String userId, Long productId) { + likeService.like(userId, productId); + } + + public void deleteLike(String userId, Long productId) { + likeService.unlike(userId, productId); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java new file mode 100644 index 000000000..683e39cdd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -0,0 +1,19 @@ +package com.loopers.application.order; + +import java.util.List; + +/** + * packageName : com.loopers.application.order + * fileName : CreateOrderCommand + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record CreateOrderCommand( + String userId, + List items +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..2fba4b4aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,81 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.application.order + * fileName : OrderFacade + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final PointService pointService; + + @Transactional + public OrderInfo createOrder(CreateOrderCommand command) { + + if (command == null || command.items() == null || command.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์ •๋ณด๊ฐ€ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค"); + } + + Order order = Order.create(command.userId()); + + for (OrderItemCommand itemCommand : command.items()) { + + //์ƒํ’ˆ๊ฐ€์ ธ์˜ค๊ณ  + Product product = productService.getProduct(itemCommand.productId()); + + // ์žฌ๊ณ ๊ฐ์†Œ + product.decreaseStock(itemCommand.quantity()); + + // OrderItem์ƒ์„ฑ + OrderItem orderItem = OrderItem.create( + product.getId(), + product.getName(), + itemCommand.quantity(), + product.getPrice()); + + order.addOrderItem(orderItem); + orderItem.setOrder(order); + } + + //์ด ๊ฐ€๊ฒฉ๊ตฌํ•˜๊ณ  + long totalAmount = order.getOrderItems().stream() + .mapToLong(OrderItem::getAmount) + .sum(); + + order.updateTotalAmount(totalAmount); + + pointService.usePoint(command.userId(), totalAmount); + + //์ €์žฅ + Order saved = orderService.createOrder(order); + saved.updateStatus(OrderStatus.COMPLETE); + + return OrderInfo.from(saved); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..70028c27c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,42 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * packageName : com.loopers.application.order + * fileName : OrderInfo + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record OrderInfo( + Long orderId, + String userId, + Long totalAmount, + OrderStatus status, + LocalDateTime createdAt, + List items +) { + public static OrderInfo from(Order order) { + List itemInfos = order.getOrderItems().stream() + .map(OrderItemInfo::from) + .toList(); + + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalAmount(), + order.getStatus(), + order.getCreatedAt(), + itemInfos + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java new file mode 100644 index 000000000..1ac46862f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemCommand.java @@ -0,0 +1,17 @@ +package com.loopers.application.order; + +/** + * packageName : com.loopers.application.order + * fileName : OrderItemCommand + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record OrderItemCommand( + Long productId, + Long quantity +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..b3f2359c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +/** + * packageName : com.loopers.application.order + * fileName : OrderInfo + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record OrderItemInfo( + Long productId, + String productName, + Long quantity, + Long price, + Long amount +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getProductName(), + item.getQuantity(), + item.getPrice(), + item.getAmount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java new file mode 100644 index 000000000..009be1cec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -0,0 +1,28 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointService; +import com.loopers.interfaces.api.point.PointV1Dto; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PointFacade { + private final PointService pointService; + + public PointInfo getPoint(String userId) { + Point point = pointService.findPointByUserId(userId); + + if (point == null) { + throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return PointInfo.from(point); + } + + public PointInfo chargePoint(PointV1Dto.ChargePointRequest request) { + return PointInfo.from(pointService.chargePoint(request.userId(), request.chargeAmount())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java new file mode 100644 index 000000000..65497297b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.Point; + +public record PointInfo(String userId, Long amount) { + public static PointInfo from(Point info) { + return new PointInfo( + info.getUserId(), + info.getBalance() + ); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java new file mode 100644 index 000000000..2a9ecee27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductDetail; + +/** + * packageName : com.loopers.application.product + * fileName : ProductDetail + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record ProductDetailInfo( + Long id, + String name, + String brandName, + Long price, + Long likeCount +) { + public static ProductDetailInfo from(ProductDetail productDetail) { + return new ProductDetailInfo( + productDetail.getId(), + productDetail.getName(), + productDetail.getBrandName(), + productDetail.getPrice(), + productDetail.getLikeCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..e6a25de23 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,47 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductDetail; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +/** + * packageName : com.loopers.application.product + * fileName : ProdcutFacade + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + private final LikeService likeService; + private final ProductDomainService productDomainService; + + public Page getProducts(String sort, Pageable pageable) { + return productService.getProducts(sort ,pageable) + .map(product -> { + Brand brand = brandService.getBrand(product.getBrandId()); + long likeCount = likeService.countByProductId(product.getId()); + return ProductInfo.of(product, brand, likeCount); + }); + } + + public ProductDetailInfo getProduct(Long id) { + ProductDetail productDetail = productDomainService.getProductDetail(id); + return ProductDetailInfo.from(productDetail); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..8bcd93dd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +/** + * packageName : com.loopers.application.product + * fileName : ProductInfo + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public record ProductInfo( + Long id, + String name, + String brandName, + Long price, + Long likeCount +) { + public static ProductInfo of(Product product, Brand brand, Long likeCount) { + return new ProductInfo( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice(), + likeCount + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..f42bd5206 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,27 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo register(String userId, String email, String birth, String gender) { + User user = userService.register(userId, email, birth, gender); + return UserInfo.from(user); + } + + public UserInfo getUser(String userId) { + User user = userService.findUserByUserId(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return UserInfo.from(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java new file mode 100644 index 000000000..08f5cea43 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,14 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; + +public record UserInfo(String userId, String email, String birth, String gender) { + public static UserInfo from(User user) { + return new UserInfo( + user.getUserId(), + user.getEmail(), + user.getBirth(), + user.getGender() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..d334ccebf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,47 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +/** + * packageName : com.loopers.domain.brand + * fileName : Brand + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Entity +@Table(name = "brand") +@Getter +public class Brand { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + protected Brand() {} + + private Brand(String name) { + this.name = requireValidName(name); + } + + public static Brand create(String name) { + return new Brand(name); + } + + + private String requireValidName(String name) { + if (name == null || name.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return name.trim(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..c558b23fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,20 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.brand + * fileName : BrandRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface BrandRepository { + Optional findById(Long id); + + void save(Brand brand); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..e0f58c77b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,35 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.brand + * fileName : BrandService + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + public void save(Brand brand) { + brandRepository.save(brand); + } + + @Transactional(readOnly = true) + public Brand getBrand(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java deleted file mode 100644 index c588c4a8a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; - -@Entity -@Table(name = "example") -public class ExampleModel extends BaseEntity { - - private String name; - private String description; - - protected ExampleModel() {} - - public ExampleModel(String name, String description) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - this.name = name; - this.description = description; - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - public void update(String newDescription) { - if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - this.description = newDescription; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java deleted file mode 100644 index 3625e5662..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.domain.example; - -import java.util.Optional; - -public interface ExampleRepository { - Optional find(Long id); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java deleted file mode 100644 index c0e8431e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class ExampleService { - - private final ExampleRepository exampleRepository; - - @Transactional(readOnly = true) - public ExampleModel getExample(Long id) { - return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] ์˜ˆ์‹œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..4430b496a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,63 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * packageName : com.loopers.domain.like + * fileName : Like + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Entity +@Table(name = "product_like") +@Getter +public class Like { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_user_id", nullable = false) + private String userId; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + @Column(nullable = false) + private LocalDateTime createdAt; + + protected Like() {} + + private Like(String userId, Long productId) { + this.userId = requireValidUserId(userId); + this.productId = requireValidProductId(productId); + this.createdAt = LocalDateTime.now(); + } + + public static Like create(String userId, Long productId) { + return new Like(userId, productId); + } + + private String requireValidUserId(String userId) { + if (userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return userId; + } + + private Long requireValidProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return productId; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..945b10235 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,25 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.like + * fileName : LikeRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface LikeRepository { + + Optional findByUserIdAndProductId(String userId, Long productId); + + void save(Like like); + + void delete(Like like); + + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..41ae90b6a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.application.like + * fileName : LikeService + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public void like(String userId, Long productId) { + if (likeRepository.findByUserIdAndProductId(userId, productId).isPresent()) return; + + Like like = Like.create(userId, productId); + likeRepository.save(like); + productRepository.incrementLikeCount(productId); + } + + @Transactional + public void unlike(String userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(like -> { + likeRepository.delete(like); + productRepository.decrementLikeCount(productId); + }); + } + + @Transactional(readOnly = true) + public long countByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..84f299c6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,86 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * packageName : com.loopers.domain.order + * fileName : Order + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Entity +@Table(name = "orders") +@Getter +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_user_id", nullable = false) + private String userId; + + @Column(nullable = false) + private Long totalAmount; + + @Enumerated(EnumType.STRING) + private OrderStatus status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderItems = new ArrayList<>(); + + protected Order() {} + + private Order(String userId, OrderStatus status) { + this.userId = requiredValidUserId(userId); + this.totalAmount = 0L; + this.status = requiredValidStatus(status); + this.createdAt = LocalDateTime.now(); + } + + public static Order create(String userId) { + return new Order(userId, OrderStatus.PENDING); + } + + public void addOrderItem(OrderItem orderItem) { + orderItem.setOrder(this); + this.orderItems.add(orderItem); + } + + private OrderStatus requiredValidStatus(OrderStatus status) { + if (status == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ƒํƒœ๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); + } + return status; + } + + private String requiredValidUserId(String userId) { + if (userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜ ์ž…๋‹ˆ๋‹ค."); + } + return userId; + } + + public void updateTotalAmount(long totalAmount) { + this.totalAmount = totalAmount; + } + + public void updateStatus(OrderStatus status) { + this.status = status; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..dce97a44a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,91 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderItem + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Entity +@Table(name = "order_item") +@Getter +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Setter + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @Column(name = "ref_product_id", nullable = false) + private Long productId; + + @Column(name = "ref_product_name", nullable = false) + private String productName; + + @Column(nullable = false) + private Long quantity; + + @Column(nullable = false) + private Long price; + + protected OrderItem() {} + + private OrderItem(Long productId, String productName, Long quantity, Long price) { + this.productId = requiredValidProductId(productId); + this.productName = requiredValidProductName(productName); + this.quantity = requiredQuantity(quantity); + this.price = requiredPrice(price); + } + + public static OrderItem create(Long productId, String productName, Long quantity, Long price) { + return new OrderItem(productId, productName, quantity, price); + } + + public Long getAmount() { + return quantity * price; + } + + private Long requiredValidProductId(Long productId) { + if (productId == null || productId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return productId; + } + + private String requiredValidProductName(String productName) { + if (productName == null || productName.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return productName; + } + + private Long requiredQuantity(Long quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return quantity; + } + + private Long requiredPrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return price; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..c80262041 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,21 @@ +package com.loopers.domain.order; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderRepository + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..a66be03d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,28 @@ +package com.loopers.domain.order; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderService + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + + @Transactional + public Order createOrder(Order order) { + return orderRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..14ea592ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,42 @@ +package com.loopers.domain.order; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderStatus + * author : byeonsungmun + * date : 2025. 11. 11. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 11. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public enum OrderStatus { + + COMPLETE("๊ฒฐ์ œ์„ฑ๊ณต"), + CANCEL("๊ฒฐ์ œ์ทจ์†Œ"), + FAIL("๊ฒฐ์ œ์‹คํŒจ"), + PENDING("๊ฒฐ์ œ์ค‘"); + + private final String description; + + OrderStatus(String description) { + this.description = description; + } + + public boolean isCompleted() { + return this == COMPLETE; + } + + public boolean isPending() { + return this == PENDING; + } + + public boolean isCanceled() { + return this == CANCEL; + } + + public String description() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java new file mode 100644 index 000000000..bc28a902a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -0,0 +1,56 @@ +package com.loopers.domain.point; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Table(name = "point") +@Getter +public class Point extends BaseEntity { + + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + private String userId; + + private Long balance; + + protected Point() {} + + private Point(String userId, Long balance) { + this.userId = requireValidUserId(userId); + this.balance = balance; + } + + public static Point create(String userId, Long balance) { + return new Point(userId, balance); + } + + String requireValidUserId(String userId) { + if(userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return userId; + } + + public void charge(Long chargeAmount) { + if (chargeAmount == null || chargeAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ํ• ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + this.balance += chargeAmount; + } + + public void use(Long useAmount) { + if (useAmount == null || useAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "0์› ์ดํ•˜๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + if (this.balance < useAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.balance -= useAmount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java new file mode 100644 index 000000000..314022491 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.point; + +import java.util.Optional; + +public interface PointRepository { + + Optional findByUserId(String userId); + + Point save(Point point); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java new file mode 100644 index 000000000..9c9570615 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,43 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class PointService { + + private final PointRepository pointRepository; + + @Transactional(readOnly = true) + public Point findPointByUserId(String userId) { + return pointRepository.findByUserId(userId).orElse(null); + } + + @Transactional + public Point chargePoint(String userId, Long chargeAmount) { + Point point = pointRepository.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ• ์ˆ˜ ์—†๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค.")); + point.charge(chargeAmount); + return pointRepository.save(point); + } + + @Transactional + public Point usePoint(String userId, Long useAmount) { + Point point = pointRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + if (useAmount == null || useAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + if (point.getBalance() < useAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + + point.use(useAmount); + return pointRepository.save(point); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..29968402f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,120 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; +import lombok.Getter; + +/** + * packageName : com.loopers.domain.product + * fileName : Product + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Entity +@Table(name = "product") +@Getter +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ref_brand_id", nullable = false) + private Long brandId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Long price; + + @Column + private Long likeCount; + + @Column(nullable = false) + private Long stock; + + protected Product() {} + + private Product(Long brandId, String name, Long price, Long likeCount, Long stock) { + this.brandId = requireValidBrandId(brandId); + this.name = requireValidName(name); + this.price = requireValidPrice(price); + this.likeCount = requireValidLikeCount(likeCount); + this.stock = requireValidStock(stock); + } + + public static Product create(Long brandId, String name, Long price, Long stock) { + return new Product( + brandId, + name, + price, + 0L, + stock + ); + } + + private Long requireValidBrandId(Long brandId) { + if (brandId == null || brandId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + return brandId; + } + + private String requireValidName(String name) { + if (name == null || name.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + return name; + } + + private Long requireValidPrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ๊ฐ€๊ฒฉ์€ 0์› ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return price; + } + + private Long requireValidLikeCount(Long likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return likeCount; + } + + private Long requireValidStock(Long stock) { + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ์žฌ๊ณ ๋Š” 0 ๋ฏธ๋งŒ์œผ๋กœ ์„ค์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + return stock; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) this.likeCount--; + } + + public void decreaseStock(Long quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (this.stock - quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.stock -= quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java new file mode 100644 index 000000000..808bff196 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDetail.java @@ -0,0 +1,45 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import lombok.Getter; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductDetail + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Getter +public class ProductDetail { + + private Long id; + private String name; + private String brandName; + private Long price; + private Long likeCount; + + protected ProductDetail() {} + + private ProductDetail(Long id, String name, String brandName, Long price, Long likeCount) { + this.id = id; + this.name = name; + this.brandName = brandName; + this.price = price; + this.likeCount = likeCount; + } + + public static ProductDetail of(Product product, Brand brand, Long likeCount) { + return new ProductDetail( + product.getId(), + product.getName(), + brand.getName(), + product.getPrice(), + likeCount + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java new file mode 100644 index 000000000..166aff66b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -0,0 +1,41 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.like.LikeRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductDetailService + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class ProductDomainService { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + + @Transactional(readOnly = true) + public ProductDetail getProductDetail(Long id) { + Product product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค")); + long likeCount = likeRepository.countByProductId(id); + + return ProductDetail.of(product, brand, likeCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..dadda62a0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,29 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductRepositroy + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface ProductRepository { + Page findAll(Pageable pageable); + + Optional findById(Long id); + + void incrementLikeCount(Long productId); + + void decrementLikeCount(Long productId); + + Product save(Product product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..067f194ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,53 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductService + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@Component +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional(readOnly = true) + public Page getProducts(String sort, Pageable pageable) { + Sort sortOption = switch (sort) { + case "price_asc" -> Sort.by("price").ascending(); + case "likes_desc" -> Sort.by("likeCount").descending(); + default -> Sort.by("createdAt").descending(); // latest + }; + + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + sortOption + ); + + return productRepository.findAll(sortedPageable); + } + + public Product getProduct(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์ด ์—†์Šต๋‹ˆ๋‹ค")); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java new file mode 100644 index 000000000..287b84cf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -0,0 +1,82 @@ +package com.loopers.domain.user; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.util.regex.Pattern; + +@Entity +@Table(name = "user") +@Getter +public class User extends BaseEntity { + + private static final Pattern USERID_PATTERN = Pattern.compile("^[a-zA-Z0-9]{1,10}$"); + private static final Pattern BIRTH_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}$"); + + @Column(unique = true, nullable = false) + private String userId; + + @Column(nullable = false) + private String email; + + @Column(nullable = false) + private String birth; + + @Column(nullable = false) + private String gender; + + protected User() {} + + public User(String userId, String email, String birth, String gender) { + this.userId = requireValidUserId(userId); + this.email = requireValidEmail(email); + this.birth = requireValidBirthDate(birth); + this.gender = requireValidGender(gender); + } + + String requireValidUserId(String userId) { + if(userId == null || userId.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (!USERID_PATTERN.matcher(userId).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + return userId; + } + + String requireValidEmail(String email) { + if(email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if(!EMAIL_PATTERN.matcher(email).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์— ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ex)xx@yy.zz"); + } + return email; + } + + String requireValidBirthDate(String birth) { + if (birth == null || birth.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์ด ๋น„์–ด ์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if(!BIRTH_PATTERN.matcher(birth).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ๋งž์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + return birth; + } + + String requireValidGender(String gender) { + if(gender == null || gender.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์„ฑ๋ณ„์ด ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค."); + } + return gender; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java new file mode 100644 index 000000000..f4b26266e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + + Optional findByUserId(String userId); + + User save(User user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java new file mode 100644 index 000000000..3cc033076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Transactional + public User register(String userId, String email, String birth, String gender) { + userRepository.findByUserId(userId).ifPresent(user -> { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); + }); + + User user = new User(userId, email, birth, gender); + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public User findUserByUserId(String userId) { + return userRepository.findByUserId(userId).orElse(null); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..759f3caf1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * packageName : com.loopers.infrastructure.brand + * fileName : BrandJpaRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface BrandJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..f23e6e5d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.brand + * fileName : BrandRepositroyImpl + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository jpaRepository; + + @Override + public Optional findById(Long id) { + return jpaRepository.findById(id); + } + + @Override + public void save(Brand brand) { + jpaRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java deleted file mode 100644 index ce6d3ead0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleJpaRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ExampleJpaRepository extends JpaRepository {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java deleted file mode 100644 index 37f2272f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/example/ExampleRepositoryImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.example; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.domain.example.ExampleRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ExampleRepositoryImpl implements ExampleRepository { - private final ExampleJpaRepository exampleJpaRepository; - - @Override - public Optional find(Long id) { - return exampleJpaRepository.findById(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..865a30db7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,23 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.like + * fileName : LikeJpaRepository + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface LikeJpaRepository extends JpaRepository { + Optional findByUserIdAndProductId(String userId, Long productId); + + long countByProductId(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..e037b6efb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.like + * fileName : LikeRepositoryImpl + * author : byeonsungmun + * date : 2025. 11. 12. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 12. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByUserIdAndProductId(String userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void save(Like like) { + likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..39cfb136d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * packageName : com.loopers.infrastructure.order + * fileName : OrderJpaRepository + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface OrderJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..f8c7b5b68 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.order + * fileName : OrderRepositroyImpl + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long orderId) { + return orderJpaRepository.findById(orderId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java new file mode 100644 index 000000000..a35a56151 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.Point; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PointJpaRepository extends JpaRepository { + + Optional findByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java new file mode 100644 index 000000000..530191b66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PointRepositoryImpl implements PointRepository { + + private final PointJpaRepository pointJpaRepository; + + @Override + public Optional findByUserId(String userId) { + return pointJpaRepository.findByUserId(userId); + } + + @Override + public Point save(Point point) { + return pointJpaRepository.save(point); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..5ceaae067 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * packageName : com.loopers.infrastructure.product + * fileName : ProductJpaRepository + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +public interface ProductJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..dbad0d9d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * packageName : com.loopers.infrastructure.product + * fileName : ProductRepositoryImpl + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@Component +@RequiredArgsConstructor +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public void incrementLikeCount(Long productId) { + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + product.increaseLikeCount(); + } + + @Override + public void decrementLikeCount(Long productId) { + Product product = productJpaRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + product.decreaseLikeCount(); + } + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..f80a5bc52 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + + Optional findByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..8fb6f7bdf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findByUserId(String userId) { + return userJpaRepository.findByUserId(userId); + } + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } +} + diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java deleted file mode 100644 index 219e3101e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1ApiSpec.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Example V1 API", description = "Loopers ์˜ˆ์‹œ API ์ž…๋‹ˆ๋‹ค.") -public interface ExampleV1ApiSpec { - - @Operation( - summary = "์˜ˆ์‹œ ์กฐํšŒ", - description = "ID๋กœ ์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getExample( - @Schema(name = "์˜ˆ์‹œ ID", description = "์กฐํšŒํ•  ์˜ˆ์‹œ์˜ ID") - Long exampleId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java deleted file mode 100644 index 917376016..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Controller.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleFacade; -import com.loopers.application.example.ExampleInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/examples") -public class ExampleV1Controller implements ExampleV1ApiSpec { - - private final ExampleFacade exampleFacade; - - @GetMapping("/{exampleId}") - @Override - public ApiResponse getExample( - @PathVariable(value = "exampleId") Long exampleId - ) { - ExampleInfo info = exampleFacade.getExample(exampleId); - ExampleV1Dto.ExampleResponse response = ExampleV1Dto.ExampleResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java deleted file mode 100644 index 4ecf0eea5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/example/ExampleV1Dto.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.interfaces.api.example; - -import com.loopers.application.example.ExampleInfo; - -public class ExampleV1Dto { - public record ExampleResponse(Long id, String name, String description) { - public static ExampleResponse from(ExampleInfo info) { - return new ExampleResponse( - info.id(), - info.name(), - info.description() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java new file mode 100644 index 000000000..6f0458399 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Point V1 API", description = "Point API ์ž…๋‹ˆ๋‹ค.") +public interface PointV1ApiSpec { + + @Operation( + summary = "ํฌ์ธํŠธ ํšŒ์› ์กฐํšŒ", + description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•œ๋‹ค." + ) + ApiResponse getPoint( + @Schema(name = "ํšŒ์› Id", description = "์กฐํšŒํ•  ํšŒ์› ID") + String userId + ); + + @Operation( + summary = "ํฌ์ธํŠธ ์ถฉ์ „", + description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•œ๋‹ค." + ) + ApiResponse chargePoint( + @Schema(name = "ํฌ์ธํŠธ ์ถฉ์ „ ์š”์ฒญ", description = "์ถฉ์ „ํ•  ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ์š”์ฒญ") + PointV1Dto.ChargePointRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..866fce9b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointFacade; +import com.loopers.application.point.PointInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/points") +public class PointV1Controller implements PointV1ApiSpec { + + private final PointFacade pointFacade; + + @Override + @GetMapping + public ApiResponse getPoint(@RequestHeader("X-USER-ID") String userId) { + PointInfo pointInfo = pointFacade.getPoint(userId); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); + return ApiResponse.success(response); + } + + @Override + @PatchMapping("/charge") + public ApiResponse chargePoint(@RequestBody PointV1Dto.ChargePointRequest request) { + PointInfo pointInfo = pointFacade.chargePoint(request); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(pointInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 000000000..b0b3d050e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,18 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointInfo; + +public class PointV1Dto { + + public record ChargePointRequest(String userId, Long chargeAmount) { + } + + public record PointResponse(String userId, Long amount) { + public static PointResponse from(PointInfo info) { + return new PointResponse( + info.userId(), + info.amount() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java new file mode 100644 index 000000000..1bed68e62 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Users V1 API", description = "Users API ์ž…๋‹ˆ๋‹ค.") +public interface UserV1ApiSpec { + + @Operation( + summary = "ํšŒ์› ๊ฐ€์ž…", + description = "ํšŒ์› ๊ฐ€์ž…์„ ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse register( + @Schema(name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", description = "ํšŒ์›๊ฐ€์ž…") + UserV1Dto.RegisterRequest request + ); + + @Operation( + summary = "ํšŒ์› ์กฐํšŒ", + description = "ํ•ด๋‹น ํšŒ์›์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse getUser( + @Schema(name = "ํšŒ์› ID", description = "์กฐํšŒํ•  ํšŒ์› ID") + String userId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java new file mode 100644 index 000000000..aed39ae1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @Override + @PostMapping("/register") + public ApiResponse register(@RequestBody UserV1Dto.RegisterRequest request) { + UserInfo userInfo = userFacade.register(request.userId(), request.mail(), request.birth(), request.gender()); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); + return ApiResponse.success(response); + } + + @Override + @GetMapping("/{userId}") + public ApiResponse getUser(@PathVariable String userId) { + UserInfo userInfo = userFacade.getUser(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(userInfo); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java new file mode 100644 index 000000000..263214848 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; + +public class UserV1Dto { + public record RegisterRequest( + String userId, + String mail, + String birth, + String gender + ) { + } + + public record UserResponse(String userId, String email, String birth, String gender) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.userId(), + info.email(), + info.birth(), + info.gender() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..9541c11f4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,42 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * packageName : com.loopers.domain.brand + * fileName : BrandTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class BrandTest { + + @DisplayName("Brand ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") + @Nested + class CreateBrandTest { + + @Test + @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ฑ ์„ฑ๊ณต") + void createBrandSuccess() { + Brand brand = Brand.create("Nike"); + assertThat(brand.getName()).isEqualTo("Nike"); + } + + @Test + @DisplayName("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์ด ์—†์œผ๋ฉด ์˜ˆ์™ธ") + void createBrandFail() { + assertThatThrownBy(() -> Brand.create("")) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java deleted file mode 100644 index 44ca7576e..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class ExampleModelTest { - @DisplayName("์˜ˆ์‹œ ๋ชจ๋ธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ œ๋ชฉ๊ณผ ์„ค๋ช…์ด ๋ชจ๋‘ ์ฃผ์–ด์ง€๋ฉด, ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") - @Test - void createsExampleModel_whenNameAndDescriptionAreProvided() { - // arrange - String name = "์ œ๋ชฉ"; - String description = "์„ค๋ช…"; - - // act - ExampleModel exampleModel = new ExampleModel(name, description); - - // assert - assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), - () -> assertThat(exampleModel.getName()).isEqualTo(name), - () -> assertThat(exampleModel.getDescription()).isEqualTo(description) - ); - } - - @DisplayName("์ œ๋ชฉ์ด ๋นˆ์นธ์œผ๋กœ๋งŒ ์ด๋ฃจ์–ด์ ธ ์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsBadRequestException_whenTitleIsBlank() { - // arrange - String name = " "; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel(name, "์„ค๋ช…"); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("์„ค๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด, BAD_REQUEST ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsBadRequestException_whenDescriptionIsEmpty() { - // arrange - String description = ""; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - new ExampleModel("์ œ๋ชฉ", description); - }); - - // assert - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java deleted file mode 100644 index bbd5fdbe1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleServiceIntegrationTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.loopers.domain.example; - -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -class ExampleServiceIntegrationTest { - @Autowired - private ExampleService exampleService; - - @Autowired - private ExampleJpaRepository exampleJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("์˜ˆ์‹œ๋ฅผ ์กฐํšŒํ•  ๋•Œ,") - @Nested - class Get { - @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") - ); - - // act - ExampleModel result = exampleService.getExample(exampleModel.getId()); - - // assert - assertAll( - () -> assertThat(result).isNotNull(), - () -> assertThat(result.getId()).isEqualTo(exampleModel.getId()), - () -> assertThat(result.getName()).isEqualTo(exampleModel.getName()), - () -> assertThat(result.getDescription()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = 999L; // Assuming this ID does not exist - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - exampleService.getExample(invalidId); - }); - - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..0be07a6fb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,155 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.*; + +/** + * packageName : com.loopers.domain.like + * fileName : LikeServiceIntegrationTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@SpringBootTest +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp cleanUp; + + @AfterEach + void tearDown() { + cleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ข‹์•„์š” ๊ธฐ๋Šฅ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + class LikeTests { + + @Test + @DisplayName("์ข‹์•„์š” ์ƒ์„ฑ ์„ฑ๊ณต โ†’ ์ข‹์•„์š” ์ €์žฅ + ์ƒํ’ˆ์˜ likeCount ์ฆ๊ฐ€") + @Transactional + void likeSuccess() { + // given + User user = userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + Product product = productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + // when + likeService.like(user.getUserId(), product.getId()); + + // then + Like saved = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); + assertThat(saved).isNotNull(); + + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(1L); + } + + @Test + @DisplayName("์ค‘๋ณต ์ข‹์•„์š” ์‹œ likeCount ์ฆ๊ฐ€ ์•ˆ ํ•˜๊ณ  ์ €์žฅ๋„ ์•ˆ ๋จ") + @Transactional + void duplicateLike() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + likeService.like("user1", 1L); + + // when + likeService.like("user1", 1L); // ์ค‘๋ณต ํ˜ธ์ถœ + + // then + long likeCount = likeRepository.countByProductId(1L); + assertThat(likeCount).isEqualTo(1L); + + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(1L); // ์ฆ๊ฐ€ X + } + + @Test + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ์„ฑ๊ณต โ†’ like ์‚ญ์ œ + ์ƒํ’ˆ์˜ likeCount ๊ฐ์†Œ") + @Transactional + void unlikeSuccess() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + likeService.like("user1", 1L); + + // when + likeService.unlike("user1", 1L); + + // then + Like like = likeRepository.findByUserIdAndProductId("user1", 1L).orElse(null); + assertThat(like).isNull(); + + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(0L); + } + + @Test + @DisplayName("์—†๋Š” ์ข‹์•„์š” ์ทจ์†Œ ์‹œ likeCount ๊ฐ์†Œ ์•ˆ ํ•จ") + @Transactional + void unlikeNonExisting() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + Product product = Product.create(1L, "์ƒํ’ˆA", 1000L, 10L); + product.increaseLikeCount(); + product.increaseLikeCount(); + product.increaseLikeCount(); + product.increaseLikeCount(); + product.increaseLikeCount(); + + productRepository.save(product); + // when โ€” ํ˜ธ์ถœ์€ ํ•ด๋„ + likeService.unlike("user1", 1L); + + // then โ€” ๋ณ€ํ™” ์—†์Œ + Product updated = productRepository.findById(1L).get(); + assertThat(updated.getLikeCount()).isEqualTo(5L); + } + + @Test + @DisplayName("countByProductId ์ •์ƒ ์กฐํšŒ") + @Transactional + void countTest() { + // given + userRepository.save(new User("user1", "u1@mail.com", "1990-01-01", "MALE")); + userRepository.save(new User("user2", "u2@mail.com", "1991-01-01", "MALE")); + productRepository.save(Product.create(1L, "์ƒํ’ˆA", 1000L, 10L)); + + likeService.like("user1", 1L); + likeService.like("user2", 1L); + + // when + long count = likeService.countByProductId(1L); + + // then + assertThat(count).isEqualTo(2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..d5b8bd851 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * packageName : com.loopers.domain.like + * fileName : LikeTest + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class LikeTest { + + + @DisplayName("์ •์ƒ์ ์œผ๋กœ Like ์—”ํ‹ฐํ‹ฐ๋ฅผ ์ƒ์„ฑ์ˆ˜ ํ•  ์žˆ๋‹ค") + @Nested + class LikeCreate { + + @DisplayName("Like์ƒ์„ฑ์ž๋ฅผ ์ƒ์„ฑํ•œ๋‹ค") + @Test + void createLike_success() { + // given + String userId = "user-001"; + Long productId = 100L; + + // when + Like like = Like.create(userId, productId); + + // then + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(productId); + assertThat(like.getCreatedAt()).isBeforeOrEqualTo(LocalDateTime.now()); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidUserId_null() { + // given + String userId = null; + Long productId = 100L; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + + @Test + @DisplayName("userId๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidUserId_empty() { + // given + String userId = ""; + Long productId = 100L; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + + @Test + @DisplayName("productId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidProductId_null() { + // given + String userId = "user-001"; + Long productId = null; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + + @Test + @DisplayName("productId๊ฐ€ 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + void createLike_invalidProductId_zeroOrNegative() { + // given + String userId = "user-001"; + Long productId = -1L; + + // when & then + assertThrows(CoreException.class, () -> Like.create(userId, productId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..149e71540 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,170 @@ +package com.loopers.domain.order; + +import com.loopers.application.order.CreateOrderCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderServiceIntegrationTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +@SpringBootTest +public class OrderServiceIntegrationTest { + + @Autowired + private OrderFacade orderFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private PointRepository pointRepository; + + @Autowired + private OrderRepository orderRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") + class OrderCreateSuccess { + + @Test + @Transactional + void createOrder_success() { + + // given + Product p1 = productRepository.save(Product.create(1L, "์•„๋ฉ”๋ฆฌ์นด๋…ธ", 3000L, 100L)); + Product p2 = productRepository.save(Product.create(1L, "๋ผ๋–ผ", 4000L, 200L)); + + pointRepository.save(Point.create("user1", 20000L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of( + new OrderItemCommand(p1.getId(), 2L), // 6000์› + new OrderItemCommand(p2.getId(), 1L) // 4000์› + ) + ); + + // when + OrderInfo info = orderFacade.createOrder(command); + + // then + Order saved = orderRepository.findById(info.orderId()).orElseThrow(); + + assertThat(saved.getStatus()).isEqualTo(OrderStatus.COMPLETE); + assertThat(saved.getTotalAmount()).isEqualTo(10000L); + assertThat(saved.getOrderItems()).hasSize(2); + + // ์žฌ๊ณ  ๊ฐ์†Œ ํ™•์ธ + Product updated1 = productRepository.findById(p1.getId()).get(); + Product updated2 = productRepository.findById(p2.getId()).get(); + assertThat(updated1.getStock()).isEqualTo(98); + assertThat(updated2.getStock()).isEqualTo(199); + + // ํฌ์ธํŠธ ๊ฐ์†Œ ํ™•์ธ + Point point = pointRepository.findByUserId("user1").get(); + assertThat(point.getBalance()).isEqualTo(10000L); // 20000 - 10000 + + } + } + + @Nested + @DisplayName("์ฃผ๋ฌธ ์‹คํŒจ ์ผ€์ด์Šค") + class OrderCreateFail { + + @Test + @Transactional + @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") + void insufficientStock_fail() { + Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 1L)); + pointRepository.save(Point.create("user1", 5000L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(item.getId(), 5L)) + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .isInstanceOf(RuntimeException.class); // ๋„ˆ์˜ ๋„๋ฉ”์ธ ์˜ˆ์™ธ ํƒ€์ž… ๋งž์ถฐ๋„ ๋จ + } + + @Test + @Transactional + @DisplayName("ํฌ์ธํŠธ ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจ") + void insufficientPoint_fail() { + Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); + pointRepository.save(Point.create("user1", 2000L)); // ๋ถ€์กฑ + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(item.getId(), 5L)) // ์ด 5000์› + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .hasMessageContaining("ํฌ์ธํŠธ"); // ๋ฉ”์‹œ์ง€ ๋งž์ถ”๋ฉด ๋” ์ •ํ™•ํ•˜๊ฒŒ ๊ฐ€๋Šฅ + } + + @Test + @Transactional + @DisplayName("์—†๋Š” ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ ์‹คํŒจ") + void noProduct_fail() { + pointRepository.save(Point.create("user1", 10000L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(999L, 1L)) + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @Transactional + @DisplayName("์œ ์ € ํฌ์ธํŠธ ์ •๋ณด ์—†์œผ๋ฉด ์‹คํŒจ") + void noUserPoint_fail() { + Product item = productRepository.save(Product.create(1L, "์ƒํ’ˆ", 1000L, 10L)); + + CreateOrderCommand command = new CreateOrderCommand( + "user1", + List.of(new OrderItemCommand(item.getId(), 1L)) + ); + + assertThatThrownBy(() -> orderFacade.createOrder(command)) + .isInstanceOf(RuntimeException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..60ed16ecc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,122 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * packageName : com.loopers.domain.order + * fileName : OrderTest + * author : byeonsungmun + * date : 2025. 11. 14. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 14. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class OrderTest { + + @Nested + @DisplayName("Order ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + class CreateOrderTest { + + @Test + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์„ฑ๊ณต") + void createOrderSuccess() { + // when + Order order = Order.create("user123"); + + // then + assertThat(order.getUserId()).isEqualTo("user123"); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(order.getTotalAmount()).isEqualTo(0L); + assertThat(order.getCreatedAt()).isNotNull(); + assertThat(order.getOrderItems()).isEmpty(); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createOrderFailUserIdNull() { + assertThatThrownBy(() -> Order.create(null)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); + } + + @Test + @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createOrderFailUserIdBlank() { + assertThatThrownBy(() -> Order.create("")) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜"); + } + } + + @Nested + @DisplayName("Order ์ƒํƒœ ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") + class UpdateStatusTest { + + @Test + @DisplayName("์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") + void updateStatusSuccess() { + // given + Order order = Order.create("user123"); + + // when + order.updateStatus(OrderStatus.COMPLETE); + + // then + assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETE); + } + } + + @Nested + @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ํ…Œ์ŠคํŠธ") + class UpdateAmountTest { + + @Test + @DisplayName("์ด์•ก ์—…๋ฐ์ดํŠธ ์„ฑ๊ณต") + void updateTotalAmountSuccess() { + // given + Order order = Order.create("user123"); + + // when + order.updateTotalAmount(5000L); + + // then + assertThat(order.getTotalAmount()).isEqualTo(5000L); + } + } + + @Nested + @DisplayName("OrderItem ์ถ”๊ฐ€ ํ…Œ์ŠคํŠธ") + class AddOrderItemTest { + + @Test + @DisplayName("OrderItem ์ถ”๊ฐ€ ์„ฑ๊ณต") + void addOrderItemSuccess() { + // given + Order order = Order.create("user123"); + + OrderItem item = OrderItem.create( + 1L, + "์ƒํ’ˆ๋ช…", + 2L, + 1000L + ); + + // when + order.addOrderItem(item); + item.setOrder(order); + + // then + assertThat(order.getOrderItems()).hasSize(1); + assertThat(order.getOrderItems().getFirst().getProductName()).isEqualTo("์ƒํ’ˆ๋ช…"); + assertThat(item.getOrder()).isEqualTo(order); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java new file mode 100644 index 000000000..b623bc9c7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -0,0 +1,108 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class PointServiceIntegrationTest { + + @Autowired + private PointRepository pointRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PointService pointService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class PointUser { + + @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnPointInfo_whenValidIdIsProvided() { + //given + String id = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + userRepository.save(new User(id, email, birth, gender)); + pointRepository.save(Point.create(id, 0L)); + + //when + Point result = pointService.findPointByUserId(id); + + //then + assertThat(result.getUserId()).isEqualTo(id); + assertThat(result.getBalance()).isEqualTo(0L); + } + + @DisplayName("ํšŒ์›์ด ์กด์žฌ ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnNull_whenInvalidUserIdIsProvided() { + //given + String id = "yh45g"; + + //when + Point point = pointService.findPointByUserId(id); + + //then + assertThat(point).isNull(); + } + } + + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class Charge { + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsChargeAmountFailException_whenUserIDIsNotProvided() { + //given + String id = "yh45g"; + + //when + CoreException exception = assertThrows(CoreException.class, () -> pointService.chargePoint(id, 1000L)); + + //then + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @Test + @DisplayName("ํšŒ์›์ด ์กด์žฌํ•˜๋ฉด ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") + void chargeSuccess() { + // given + String userId = "user2"; + userRepository.save(new User(userId, "yh45g@loopers.com", "1994-12-05", "MALE")); + pointRepository.save(Point.create(userId, 1000L)); + + // when + Point updated = pointService.chargePoint(userId, 500L); + + // then + assertThat(updated.getBalance()).isEqualTo(1500L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java new file mode 100644 index 000000000..f33fb2821 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +class PointTest { + + @Nested + @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + class CreatePointTest { + + @Test + @DisplayName("ํฌ์ธํŠธ ์ƒ์„ฑ ์„ฑ๊ณต") + void createPointSuccess() { + // when + Point point = Point.create("user123", 100L); + + // then + assertThat(point.getUserId()).isEqualTo("user123"); + assertThat(point.getBalance()).isEqualTo(100L); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createPointFailUserIdNull() { + assertThatThrownBy(() -> Point.create(null, 100L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("userId๊ฐ€ ๊ณต๋ฐฑ์ด๋ฉด ์ƒ์„ฑ ์‹คํŒจ") + void createPointFailUserIdBlank() { + assertThatThrownBy(() -> Point.create("", 100L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๊ฐ€ ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + @Nested + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ํ…Œ์ŠคํŠธ") + class ChargePointTest { + + @Test + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์„ฑ๊ณต") + void chargeSuccess() { + // given + Point point = Point.create("user123", 100L); + + // when + point.charge(50L); + + // then + assertThat(point.getBalance()).isEqualTo(150L); + } + + @Test + @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") + void chargeFailZeroOrNegative() { + Point point = Point.create("user123", 100L); + + assertThatThrownBy(() -> point.charge(0L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ถฉ์ „"); + + assertThatThrownBy(() -> point.charge(-10L)) + .isInstanceOf(CoreException.class); + } + } + + @Nested + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ํ…Œ์ŠคํŠธ") + class UsePointTest { + + @Test + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์„ฑ๊ณต") + void useSuccess() { + // given + Point point = Point.create("user123", 100L); + + // when + point.use(40L); + + // then + assertThat(point.getBalance()).isEqualTo(60L); + } + + @Test + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - 0 ์ดํ•˜ ์ž…๋ ฅ") + void useFailZeroOrNegative() { + Point point = Point.create("user123", 100L); + + assertThatThrownBy(() -> point.use(0L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + + assertThatThrownBy(() -> point.use(-10L)) + .isInstanceOf(CoreException.class); + } + + @Test + @DisplayName("ํฌ์ธํŠธ ์‚ฌ์šฉ ์‹คํŒจ - ์ž”์•ก ๋ถ€์กฑ") + void useFailNotEnough() { + Point point = Point.create("user123", 50L); + + assertThatThrownBy(() -> point.use(100L)) + .isInstanceOf(CoreException.class) + .hasMessageContaining("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑ"); + } + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..8ad61a194 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,43 @@ +package com.loopers.domain.product; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductServiceIntegrationTest + * author : byeonsungmun + * date : 2025. 11. 13. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 13. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ + +@SpringBootTest +public class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ํ…Œ์ŠคํŠธ") + class ProductListTests { + + Product product; + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..c2c6fdd9b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,95 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * packageName : com.loopers.domain.product + * fileName : ProductTest + * author : byeonsungmun + * date : 2025. 11. 10. + * description : + * =========================================== + * DATE AUTHOR NOTE + * ------------------------------------------- + * 2025. 11. 10. byeonsungmun ์ตœ์ดˆ ์ƒ์„ฑ + */ +class ProductTest { + @DisplayName("Product ์ข‹์•„์š” ์ˆ˜ ์ฆ๊ฐ ํ…Œ์ŠคํŠธ") + @Nested + class LikeCountChange { + + @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค.") + @Test + void increaseLikeCount_incrementsLikeCount() { + // given + Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + // when + product.increaseLikeCount(); + + // then + assertEquals(1L, product.getLikeCount()); + } + + @DisplayName("์ข‹์•„์š” ์ˆ˜๋ฅผ ๊ฐ์†Œ์‹œํ‚จ๋‹ค. 0 ๋ฏธ๋งŒ์œผ๋กœ๋Š” ๊ฐ์†Œํ•˜์ง€ ์•Š๋Š”๋‹ค.") + @Test + void decreaseLikeCount_decrementsLikeCountButNotBelowZero() { + // given + Product product = Product.create(11L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 1L); + + // when + product.decreaseLikeCount(); + + // then + assertEquals(0L, product.getLikeCount()); + + // when decrease again + product.decreaseLikeCount(); + + // then likeCount should not go below 0 + assertEquals(0L, product.getLikeCount()); + } + } + + @DisplayName("Product ์žฌ๊ณ  ์ฐจ๊ฐ ํ…Œ์ŠคํŠธ") + @Nested + class Stock { + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ •์ƒ ์ฐจ๊ฐํ•œ๋‹ค.") + @Test + void decreaseStock_successfullyDecreasesStock() { + // given + Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + // when + product.decreaseStock(3L); + + // then + assertEquals(7, product.getStock()); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜์ด๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void decreaseStock_withInvalidQuantity_throwsException() { + Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + assertThrows(CoreException.class, () -> product.decreaseStock(0L)); + assertThrows(CoreException.class, () -> product.decreaseStock(-1L)); + } + + @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ํฐ ์ˆ˜๋Ÿ‰ ์ฐจ๊ฐ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ") + @Test + void decreaseStock_withInsufficientStock_throwsException() { + Product product = Product.create(1L, "๋‚˜์ดํ‚ค ์—์–ด2", 30000L, 10L); + + assertThrows(CoreException.class, () -> product.decreaseStock(11L)); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java new file mode 100644 index 000000000..71091883f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,112 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +@SpringBootTest +class UserServiceIntegrationTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserService userService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์› ๊ฐ€์ž… ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class UserRegister { + + @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") + @Test + void save_whenUserRegister() { + //given + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String brith = "1994-12-05"; + String gender = "Male"; + + UserRepository userRepositorySpy = spy(userRepository); + UserService userServiceSpy = new UserService(userRepositorySpy); + + //when + userServiceSpy.register(userId, email, brith, gender); + + //then + verify(userRepositorySpy).save(any(User.class)); + } + + @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenDuplicateUserId() { + //given + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String brith = "1994-12-05"; + String gender = "Male"; + + //when + userService.register(userId, email, brith, gender); + + //then + Assertions.assertThrows(CoreException.class, () + -> userService.register(userId, email, brith, gender)); + } + } + + @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") + @Nested + class Get { + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnsUser_whenValidIdIsProvided() { + //given + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String brith = "1994-12-05"; + String gender = "Male"; + + //when + userService.register(userId, email, brith, gender); + User user = userService.findUserByUserId(userId); + + //then + assertAll( + () -> assertThat(user.getUserId()).isEqualTo(userId), + () -> assertThat(user.getEmail()).isEqualTo(email), + () -> assertThat(user.getBirth()).isEqualTo(brith), + () -> assertThat(user.getGender()).isEqualTo(gender) + ); + } + + @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void returnNull_whenInvalidUserIdIsProvided() { + //given + String userId = "yh45g"; + + //when + User user = userService.findUserByUserId(userId); + + //then + assertThat(user).isNull(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java new file mode 100644 index 000000000..7d74fdfe2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -0,0 +1,53 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserTest { + @DisplayName("User ๋‹จ์œ„ ํ…Œ์ŠคํŠธ") + @Nested + class Create { + @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidUserIdFormat() { + // given + String invalidUserId = "invalid_id_123"; // 10์ž ์ดˆ๊ณผ + ํŠน์ˆ˜๋ฌธ์ž ํฌํ•จ + String email = "valid@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + // when & then + assertThrows(CoreException.class, () -> new User(invalidUserId, email, birth, gender)); + } + + @DisplayName("์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidEmailFormat() { + // given + String userId = "yh45g"; + String invalidEmail = "invalid-email-format"; // '@' ์—†์Œ + String birth = "1994-12-05"; + String gender = "MALE"; + + // when & then + assertThrows(CoreException.class, () -> new User(userId, invalidEmail, birth, gender)); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidBirthFormat() { + // given + String userId = "yh45g"; + String email = "valid@loopers.com"; + String invalidBirth = "19941205"; // ํ˜•์‹ ์˜ค๋ฅ˜: ํ•˜์ดํ”ˆ ์—†์Œ + String gender = "MALE"; + + // when & then + assertThrows(CoreException.class, () -> new User(userId, email, invalidBirth, gender)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java deleted file mode 100644 index 1bb3dba65..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ExampleV1ApiE2ETest.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.example.ExampleModel; -import com.loopers.infrastructure.example.ExampleJpaRepository; -import com.loopers.interfaces.api.example.ExampleV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.util.function.Function; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -class ExampleV1ApiE2ETest { - - private static final Function ENDPOINT_GET = id -> "/api/v1/examples/" + id; - - private final TestRestTemplate testRestTemplate; - private final ExampleJpaRepository exampleJpaRepository; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public ExampleV1ApiE2ETest( - TestRestTemplate testRestTemplate, - ExampleJpaRepository exampleJpaRepository, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.exampleJpaRepository = exampleJpaRepository; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("GET /api/v1/examples/{id}") - @Nested - class Get { - @DisplayName("์กด์žฌํ•˜๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, ํ•ด๋‹น ์˜ˆ์‹œ ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnsExampleInfo_whenValidIdIsProvided() { - // arrange - ExampleModel exampleModel = exampleJpaRepository.save( - new ExampleModel("์˜ˆ์‹œ ์ œ๋ชฉ", "์˜ˆ์‹œ ์„ค๋ช…") - ); - String requestUrl = ENDPOINT_GET.apply(exampleModel.getId()); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(exampleModel.getId()), - () -> assertThat(response.getBody().data().name()).isEqualTo(exampleModel.getName()), - () -> assertThat(response.getBody().data().description()).isEqualTo(exampleModel.getDescription()) - ); - } - - @DisplayName("์ˆซ์ž๊ฐ€ ์•„๋‹Œ ID ๋กœ ์š”์ฒญํ•˜๋ฉด, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") - @Test - void throwsBadRequest_whenIdIsNotProvided() { - // arrange - String requestUrl = "/api/v1/examples/๋‚˜๋‚˜"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์˜ˆ์‹œ ID๋ฅผ ์ฃผ๋ฉด, 404 NOT_FOUND ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") - @Test - void throwsException_whenInvalidIdIsProvided() { - // arrange - Long invalidId = -1L; - String requestUrl = ENDPOINT_GET.apply(invalidId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; - ResponseEntity> response = - testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is4xxClientError()), - () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) - ); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java new file mode 100644 index 000000000..7d7a2c18c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/point/PointV1ControllerTest.java @@ -0,0 +1,156 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.domain.point.Point; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PointV1ControllerTest { + + private static final String GET_USER_POINT_ENDPOINT = "/api/v1/points"; + private static final String POST_USER_POINT_ENDPOINT = "/api/v1/points/charge"; + + @Autowired + private PointRepository pointRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @Autowired + private TestRestTemplate testRestTemplate; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("GET /api/v1/points") + @Nested + class UserPoint { + + @DisplayName("ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnPoint_whenValidUserIdIsProvided() { + //given + String id = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + Long amount = 1000L; + + userRepository.save(new User(id, email, birth, gender)); + pointRepository.save(Point.create(id, amount)); + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(id), + () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) + ); + } + + @DisplayName("ํ•ด๋‹น ID์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnNull_whenUserIdExists() { + //given + String id = "yh45g"; + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(GET_USER_POINT_ENDPOINT, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getBody().data()).isNull() + ); + } + } + + @DisplayName("POST /api/v1/points/charge") + @Nested + class Charge { + + @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsTotalPoint_whenChargeUserPoint() { + //given + String id = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + userRepository.save(new User(id, email, birth, gender)); + pointRepository.save(Point.create(id, 0L)); + + PointV1Dto.ChargePointRequest request = new PointV1Dto.ChargePointRequest(id, 1000L); + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(request, headers), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(id), + () -> assertThat(response.getBody().data().amount()).isEqualTo(1000L) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidUserIdIsProvided() { + //given + String id = "yh45g"; + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-USER-ID", id); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(POST_USER_POINT_ENDPOINT, HttpMethod.PATCH, new HttpEntity<>(null, headers), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java new file mode 100644 index 000000000..defe2fcd5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerTest.java @@ -0,0 +1,148 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class UserV1ControllerTest { + + private static final String USER_REGISTER_ENDPOINT = "/api/v1/users/register"; + private static final Function GET_USER_ENDPOINT = id -> "/api/v1/users/" + id; + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class RegisterUser { + @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void registerUser_whenSuccessResponseUser() { + //given + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().email()).isEqualTo(email), + () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), + () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) + ); + } + @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, 400 BAD_REQUEST ์‘๋‹ต์„ ๋ฐ›๋Š”๋‹ค.") + @Test + void throwsBadRequest_whenGenderIsNotProvided() { + //given + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = null; + + UserV1Dto.RegisterRequest request = new UserV1Dto.RegisterRequest(userId, email, birth, gender); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(USER_REGISTER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("GET /api/v1/users/{userId}") + @Nested + class GetUserById { + @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void getUserById_whenSuccessResponseUser() { + //given + String userId = "yh45g"; + String email = "yh45g@loopers.com"; + String birth = "1994-12-05"; + String gender = "MALE"; + + userJpaRepository.save(new User(userId, email, birth, gender)); + + String requestUrl = GET_USER_ENDPOINT.apply(userId); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userId), + () -> assertThat(response.getBody().data().email()).isEqualTo(email), + () -> assertThat(response.getBody().data().birth()).isEqualTo(birth), + () -> assertThat(response.getBody().data().gender()).isEqualTo(gender) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void throwsException_whenInvalidUserIdIsProvided() { + //given + String userId = "notUserId"; + String requestUrl = GET_USER_ENDPOINT.apply(userId); + + //when + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + //then + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/docs/1round/1round.md b/docs/1round/1round.md new file mode 100644 index 000000000..106d6c809 --- /dev/null +++ b/docs/1round/1round.md @@ -0,0 +1,67 @@ +## ๐Ÿงช Implementation Quest + +> ์ง€์ •๋œ **๋‹จ์œ„ ํ…Œ์ŠคํŠธ / ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ / E2E ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค**๋ฅผ ํ•„์ˆ˜๋กœ ๊ตฌํ˜„ํ•˜๊ณ , ๋ชจ๋“  ํ…Œ์ŠคํŠธ๋ฅผ ํ†ต๊ณผ์‹œํ‚ค๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•ฉ๋‹ˆ๋‹ค. +> + +### ํšŒ์› ๊ฐ€์ž… + +**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** + +- [x] ID ๊ฐ€ `์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [x] ์ด๋ฉ”์ผ์ด `xx@yy.zz` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. +- [x] ์ƒ๋…„์›”์ผ์ด `yyyy-MM-dd` ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [x] ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค. ( spy ๊ฒ€์ฆ ) +- [x] ์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [x] ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ๋‚ด ์ •๋ณด ์กฐํšŒ + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [x] ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ํฌ์ธํŠธ ์กฐํšŒ + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- [x] ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [x] ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [x] `X-USER-ID` ํ—ค๋”๊ฐ€ ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +### ํฌ์ธํŠธ ์ถฉ์ „ + +**๐Ÿงฑ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ** + +- [X] 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. + +**๐Ÿ”— ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ** + +- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. + +**๐ŸŒ E2E ํ…Œ์ŠคํŠธ** + +- [X] ์กด์žฌํ•˜๋Š” ์œ ์ €๊ฐ€ 1000์›์„ ์ถฉ์ „ํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- [X] ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. + +## โœ… Checklist + +- [X] ํ…Œ์ŠคํŠธ๋Š” ๋ชจ๋‘ ํ†ต๊ณผํ•ด์•ผ ํ•˜๋ฉฐ, `@Test` ๊ธฐ๋ฐ˜์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ์ž‘์„ฑ +- [X] ๊ฐ ํ…Œ์ŠคํŠธ๋Š” ํ…Œ์ŠคํŠธ ๋ช…/์„ค๋ช…/์ž…๋ ฅ/์˜ˆ์ƒ ๊ฒฐ๊ณผ๊ฐ€ ๋ถ„๋ช…ํ•ด์•ผ ํ•จ +- [X] E2E ํ…Œ์ŠคํŠธ๋Š” ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํ๋ฆ„์„ ๊ฒ€์ฆํ•  ๊ฒƒ \ No newline at end of file diff --git a/docs/2round/01-requirements.md b/docs/2round/01-requirements.md new file mode 100644 index 000000000..3296c21c6 --- /dev/null +++ b/docs/2round/01-requirements.md @@ -0,0 +1,104 @@ +# ์œ ์ €-์‹œ๋‚˜๋ฆฌ์˜ค + +## ์ƒํ’ˆ ๋ชฉ๋ก +1. ์‚ฌ์šฉ์ž๊ฐ€ ๋ชจ๋“  ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ๋ณผ์ˆ˜ ์žˆ๋‹ค. +2. ํŒ๋งค์ค‘์ธ ์ƒํ’ˆ์— ๋Œ€ํ•œ ํŒ๋งค๋ช…, ํŒ๋งค๊ธˆ์•ก, ํŒ๋งค๋ธŒ๋žœ๋“œ, ์ด๋ฏธ์ง€, ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. +3. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ๋ธŒ๋žœ๋“œ๋ณ„๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜ ์žˆ๋‹ค. +4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์ƒํ’ˆ์—๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ์ˆ˜์žˆ๋‹ค. +5. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. +6. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. + +-[๊ธฐ๋Šฅ] +1. ์ „์ฒด ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +2. ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก +4. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) +5. ํŽ˜์ด์ง• + +-[์ œ์•ฝ] +1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค +2. ์ƒํ’ˆ์ด ์—†์„๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. + +--- +## ์ƒํ’ˆ ์ƒ์„ธ +1. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŒ๋งค์ค‘ ์ƒํ’ˆ(ํŒ๋งค๋ช…,ํŒ๋งค๊ธˆ์•ก,ํŒ๋งค๋ธŒ๋žœ๋“œ,์ด๋ฏธ์ง€,์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. + -[๊ธฐ๋Šฅ] +1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ๋ฒˆํ˜ธ๋กœ ์กฐํšŒ +2. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก / ์ทจ์†Œ + -[์ œ์•ฝ] +1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค +2. ์ƒํ’ˆ์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ 404_NOTFOUND ์ œ๊ณตํ•œ๋‹ค. + +--- +## ์ข‹์•„์š” +1. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅผ ์ˆ˜ ์žˆ๋‹ค. +2. ์‚ฌ์šฉ์ž๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด์„œ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œ ํ•  ์ˆ˜ ์žˆ๋‹ค. +2. ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•ด ๋ชฉ๋ก์„ ๋ณผ์ˆ˜์žˆ๋‹ค. + -[๊ธฐ๋Šฅ] +1. ์ข‹์•„์š” ๋ˆ„๋ฅธ ์ƒํ’ˆ์—๋Œ€ํ•ด ๋ชฉ๋ก ์กฐํšŒ +2. ์‚ฌ์šฉ๊ฐ€ ์›ํ•˜๋Š” ์ƒํ’ˆ์— ๋Œ€ํ•œ ๋“ฑ๋ก/์ทจ์†Œ, ๋‹จ ๋“ฑ๋ก/ํ•ด์ œ (๋ฉฑ๋“ฑ์„ฑ) + -[์ œ์•ฝ] +1. ๋กœ๊ทธ์ธ์€ X-USER-ID๋กœ ํŒ๋‹จ, ๋น„ํšŒ์›์ผ๊ฒฝ์šฐ "๋กœ๊ทธ์ธ ํ›„ ์ข‹์•„์š” ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค" ๋ฉ”์‹œ์ง€ ์ „๋‹ฌํ•œ๋‹ค +2. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ์ฒ˜์Œ ๋“ฑ๋ก ํ• ๋•Œ๋Š” 201_Created ์ œ๊ณตํ•œ๋‹ค +3. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ํ•œ๋ฒˆ๋” ๋“ฑ๋ก ํ• ๋•Œ๋Š” 200_OK ์ œ๊ณตํ•œ๋‹ค +4. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ๋“ฑ๋ก ๋œ ์ƒํƒœ์—์„œ ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค +5. ํ•ด๋‹น ์ƒํ’ˆ์— ์ข‹์•„์š”๊ฐ€ ์ทจ์†Œ๊ฐ€ ๋œ ์ƒํƒœ์—์„œ ํ•œ๋ฒˆ๋” ์ทจ์†Œ ํ• ๋•Œ๋Š” 204 No Content ์ œ๊ณตํ•œ๋‹ค +--- +## ๋ธŒ๋žœ๋“œ +1. ์‚ฌ์šฉ์ž๋Š” ๋ชจ๋“  ๋ธŒ๋žœ๋“œ์˜ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. +2. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๋ธŒ๋žœ๋“œ์— ๋Œ€ํ•œ ์ƒํ’ˆ๋งŒ ๋ณผ์ˆ˜์žˆ๋‹ค. +3. ์‚ฌ์šฉ์ž๋Š” ํŠน์ • ๊ธฐ์ค€์œผ๋กœ ์ƒํ’ˆ์„ ๋ณผ์ˆ˜์žˆ๋‹ค. (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ) +4. ์‚ฌ์šฉ์ž๋Š” ์›ํ•˜๋Š” ํŽ˜์ด์ง€๋กœ ์ด๋™ํ• ์ˆ˜์žˆ๋‹ค. + [๊ธฐ๋Šฅ] +1. ๋ชจ๋“  ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ +2. ํŠน์ • ๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ์กฐํšŒ +3. ํ•„ํ„ฐ๋ง (์ตœ์‹ ์ˆœ, ํŒ๋งค์ˆœ, ์ข‹์•„์š” ์ˆœ, ๋‚จ์ž/์—ฌ์ž) +4. ํŽ˜์ด์ง• + [์ œ์•ฝ] +1. ๋ธŒ๋žœ๋“œ๊ฐ€ ์—†์„์‹œ 404_NOTFOUND๋ฅผ ์ œ๊ณตํ•œ๋‹ค +--- +## ์ฃผ๋ฌธ +1. ์‚ฌ์šฉ์ž๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก์—์„œ ์›ํ•˜๋Š” ์ƒํ’ˆ์„ ์„ ํƒํ•˜์—ฌ ์ฃผ๋ฌธํ•  ์ˆ˜ ์žˆ๋‹ค. +2. ํ•œ๊ฐœ์˜ ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ๋‹ค. +3. ์‚ฌ์šฉ์ž๊ฐ€ ์ฃผ๋ฌธ ๋‚ด์—ญ์„ ์กฐํšŒํ•ด ์–ด๋–ค ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ–ˆ๋Š”์ง€ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. +4. ์‚ฌ์šฉ์ž๋Š” ๊ฒฐ์ œ ์ „์ด๋ผ๋ฉด ์ฃผ๋ฌธ์„ ์ทจ์†Œํ•  ์ˆ˜ ์žˆ๋‹ค. +5. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธ ์ƒ์„ธ ํ™”๋ฉด์—์„œ ์ƒํ’ˆ ์ •๋ณด, ์ˆ˜๋Ÿ‰, ๊ฒฐ์ œ ๊ธˆ์•ก, ์ƒํƒœ ๋“ฑ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. + [๊ธฐ๋Šฅ] +1. ์ฃผ๋ฌธ ์ƒ์„ฑ +2. ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ +3. ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ +4. ์ฃผ๋ฌธ ์ทจ์†Œ +5. ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ์ƒํƒœ๊ด€๋ฆฌ + [์ œ์•ฝ] +1. ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ ์žฌ๊ณ  ํ™•์ธ ๋ฐ ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ ๋ฐœ์ƒ +2. ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ ์‹œ ์ฃผ๋ฌธ ๋ถˆ๊ฐ€ +3. ๋™์ผํ•œ ์ฃผ๋ฌธ ์š”์ฒญ์ด ์ค‘๋ณต์œผ๋กœ ๋“ค์–ด์™€๋„ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ +--- +## ๊ฒฐ์ œ +1. ์‚ฌ์šฉ์ž๋Š” ์ฃผ๋ฌธํ•œ ์ƒํ’ˆ์— ๋Œ€ํ•ด ํฌ์ธํŠธ๋กœ ๊ฒฐ์ œํ•  ์ˆ˜ ์žˆ๋‹ค. +2. ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด ์ฃผ๋ฌธ ์ƒํƒœ๊ฐ€ ๊ฒฐ์ œ ์™„๋ฃŒ๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค. +3. ๊ฒฐ์ œ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๊ฒฐ์ œ ์‹คํŒจ๋กœ ์ฒ˜๋ฆฌ๋˜๋ฉฐ ํฌ์ธํŠธ์™€ ์žฌ๊ณ ๋Š” ๋ณต๊ตฌ๋œ๋‹ค. +4. ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„์—๋Š” ์ฃผ๋ฌธ ์ทจ์†Œ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค. + [๊ธฐ๋Šฅ] +1. ๊ฒฐ์ œ์š”์ฒญ +2. ๊ฒฐ์ œ ๊ฒฐ๊ณผ ๋ฐ˜์˜ +3. ๊ฒฐ์ œ ์‹คํŒจ ์ฒ˜๋ฆฌ +4. ๊ฒฐ์ œ ๋‚ด์—ญ ์กฐํšŒ + [์ œ์•ฝ] +1. ๋™์ผ ์ฃผ๋ฌธ์— ๋Œ€ํ•ด ์ค‘๋ณต ๊ฒฐ์ œ ์š”์ฒญ ์‹œ ํ•œ ๋ฒˆ๋งŒ ์ฒ˜๋ฆฌ +2. ํฌ์ธํŠธ ์ฐจ๊ฐ์‹คํŒจ ์‹œ ๋ณต๊ตฌ +3. ์™ธ๋ถ€๊ฒฐ์ œ ์‹œ์Šคํ…œ ๊ฒฐ์ œ ์‹œ์Šคํ…œ ์ฒ˜๋ฆฌ ์‹คํŒจ์‹œ ์˜ˆ์™ธ์ฒ˜๋ฆฌ + +---- +## Ubiquitous +| ํ•œ๊ตญ์–ด | ์˜์–ด | +|--------|------| +| ์‚ฌ์šฉ์ž | User | +| ํฌ์ธํŠธ | Point | +| ์ƒํ’ˆ | Product | +| ๋ธŒ๋žœ๋“œ | Brand | +| ์ข‹์•„์š” | Like | +| ์ฃผ๋ฌธ | Order | +| ์žฌ๊ณ  | Stock | +| ๊ฐ€๊ฒฉ | Price | +| ๊ฒฐ์ œ | Payment | \ No newline at end of file diff --git a/docs/2round/02-sequence-diagrams.md b/docs/2round/02-sequence-diagrams.md new file mode 100644 index 000000000..5264a4dc0 --- /dev/null +++ b/docs/2round/02-sequence-diagrams.md @@ -0,0 +1,164 @@ +# ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +### 1. ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + participant BrandRepository + participant LikeRepository + + User->>ProductController: GET /api/v1/products + ProductController->>ProductService: getProductList + ProductService->>ProductRepository: findAllWithPaging + ProductService->>BrandRepository: findBrandInfoForProducts() + ProductService->>LikeRepository: countLikesForProducts() + ProductRepository-->>ProductService: productList + ProductService-->>ProductController: productListResponse + ProductController-->>User: 200 OK (์ƒํ’ˆ ๋ชฉ๋ก + ๋ธŒ๋žœ๋“œ + ์ข‹์•„์š” ์ˆ˜) +``` +--- +### 2. ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductService + participant ProductRepository + participant BrandRepository + participant LikeRepository + + User->>ProductController: GET /api/v1/products/{productId} + ProductController->>ProductService: getProductDetail(productId, userId) + ProductService->>ProductRepository: findById(productId) + ProductService->>BrandRepository: findBrandInfo(brandId) + ProductService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + ProductRepository-->>ProductService: productDetail + ProductService-->>ProductController: productDetailResponse + ProductController-->>User: 200 OK (์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด) +``` +--- +### 3. ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ +```mermaid +sequenceDiagram + participant User + participant LikeController + participant LikeService + participant LikeRepository + + User->>LikeController: POST /api/v1/like/products/{productId} + LikeController->>LikeService: toggleLike(userId, productId) + LikeService->>LikeRepository: existsByUserIdAndProductId(userId, productId) + alt ์ข‹์•„์š”๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Œ + LikeService->>LikeRepository: save(userId, productId) + LikeService-->>LikeController: 201 Created + else ์ด๋ฏธ ์ข‹์•„์š” ๋˜์–ด์žˆ์Œ + LikeService->>LikeRepository: delete(userId, productId) + LikeService-->>LikeController: 204 No Content + end + LikeController-->>User: ์‘๋‹ต (์ƒํƒœ์ฝ”๋“œ์— ๋”ฐ๋ผ ๋‹ค๋ฆ„) +``` +--- + +### 4. ๋ธŒ๋žœ๋“œ๋ณ„ ์ƒํ’ˆ ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant BrandController + participant BrandService + participant ProductRepository + participant BrandRepository + + User->>BrandController: GET /api/v1/brands/{brandId}/products + BrandController->>BrandService: getProductsByBrand(brandId, sort, page) + BrandService->>BrandRepository: findById(brandId) + BrandService->>ProductRepository: findByBrandId(brandId, sort, page) + BrandRepository-->>BrandService: brandInfo + ProductRepository-->>BrandService: productList + BrandService-->>BrandController: productListResponse + BrandController-->>User: 200 OK (๋ธŒ๋žœ๋“œ ์ƒํ’ˆ ๋ชฉ๋ก) +``` +--- +### 5. ์ฃผ๋ฌธ ์ƒ์„ฑ +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderService + participant ProductReader + participant StockService + participant PointService + participant OrderRepository + + User->>OrderController: POST /api/v1/orders (items[]) + OrderController->>OrderService: createOrder(userId, items) + OrderService->>ProductReader: getProductsByIds(productIds) + loop ๊ฐ ์ƒํ’ˆ์— ๋Œ€ํ•ด + OrderService->>StockService: checkAndDecreaseStock(productId, quantity) + end + OrderService->>PointService: deductPoint(userId, totalPrice) + alt ์žฌ๊ณ  ๋˜๋Š” ํฌ์ธํŠธ ๋ถ€์กฑ + OrderService-->>OrderController: throw Exception + OrderController-->>User: 400 Bad Request + else ์ •์ƒ + OrderService->>OrderRepository: save(order, orderItems) + OrderService-->>OrderController: OrderResponse + OrderController-->>User: 201 Created (์ฃผ๋ฌธ ์™„๋ฃŒ) + end +``` +--- +### 6. ์ฃผ๋ฌธ ๋ชฉ๋ก ๋ฐ ์ƒ์„ธ ์กฐํšŒ +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderService + participant OrderRepository + participant ProductRepository + + User->>OrderController: GET /api/v1/orders + OrderController->>OrderService: getOrderList(userId) + OrderService->>OrderRepository: findByUserId(userId) + OrderRepository-->>OrderService: orderList + OrderService-->>OrderController: orderListResponse + OrderController-->>User: 200 OK (์ฃผ๋ฌธ ๋ชฉ๋ก) + + User->>OrderController: GET /api/v1/orders/{orderId} + OrderController->>OrderService: getOrderDetail(orderId, userId) + OrderService->>OrderRepository: findById(orderId) + OrderService->>ProductRepository: findProductsInOrder(orderId) + OrderRepository-->>OrderService: orderDetail + OrderService-->>OrderController: orderDetailResponse + OrderController-->>User: 200 OK (์ฃผ๋ฌธ ์ƒ์„ธ) +``` +--- +### 7. ๊ฒฐ์ œ ์ฒ˜๋ฆฌ +```mermaid +sequenceDiagram + participant User + participant PaymentController + participant PaymentService + participant PaymentGateway + participant OrderRepository + participant PointService + participant StockService + + User->>PaymentController: POST /api/v1/payments (orderId) + PaymentController->>PaymentService: processPayment(orderId, userId) + PaymentService->>OrderRepository: findById(orderId) + PaymentService->>PaymentGateway: requestPayment(orderId, amount) + alt ๊ฒฐ์ œ ์„ฑ๊ณต + PaymentGateway-->>PaymentService: SUCCESS + PaymentService->>OrderRepository: updateStatus(orderId, PAID) + PaymentService-->>PaymentController: successResponse + PaymentController-->>User: 200 OK (๊ฒฐ์ œ ์™„๋ฃŒ) + else ๊ฒฐ์ œ ์‹คํŒจ + PaymentGateway-->>PaymentService: FAILED + PaymentService->>PointService: rollbackPoint(userId, amount) + PaymentService->>StockService: restoreStock(orderId) + PaymentService->>OrderRepository: updateStatus(orderId, FAILED) + PaymentController-->>User: 500 Internal Server Error (๊ฒฐ์ œ ์‹คํŒจ) + end +``` diff --git a/docs/2round/03-class-diagram.md b/docs/2round/03-class-diagram.md new file mode 100644 index 000000000..8d39cfd0a --- /dev/null +++ b/docs/2round/03-class-diagram.md @@ -0,0 +1,78 @@ +# ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ + +```mermaid +classDiagram +direction TB + +class User { + Long id + String userId + String name + String email + String gender +} + +class Point { + Long id + String userId + Long balance +} + +class Brand { + Long id + String name +} + +class Product { + Long id + Long brandId + String name + Long price + Long likeCount; + Long stock +} + +class Like { + Long id + String userId + Long productId + LocalDateTime createdAt +} + +class Order { + Long id + String userId + Long totalPrice + OrderStatus status + LocalDateTime createdAt + List orderItems +} + +class OrderItem { + Long id + Order order + Long productId + String productName + Long quantity + Long price +} + +class Payment { + Long id + Long orderId + String status + String paymentRequestId + LocalDateTime createdAt +} + +%% ๊ด€๊ณ„ ์„ค์ • +User --> Point +Brand --> Product +Product --> Like +User --> Like +User --> Order +Order --> OrderItem +Order --> Payment +OrderItem --> Product + +``` \ No newline at end of file diff --git a/docs/2round/04-erd.md b/docs/2round/04-erd.md new file mode 100644 index 000000000..6389b2202 --- /dev/null +++ b/docs/2round/04-erd.md @@ -0,0 +1,74 @@ +# erd + +```mermaid +erDiagram + USER { + bigint id PK + varchar user_id + varchar name + varchar email + varchar gender + } + + POINT { + bigint id PK + varchar user_id FK + bigint balance + } + + BRAND { + bigint id PK + varchar name + } + + PRODUCT { + bigint id PK + bigint brand_id FK + varchar name + bigint price + bigint like_count + bigint stock + } + + LIKE { + bigint id PK + varchar user_id FK + bigint product_id FK + datetime created_at + } + + ORDERS { + bigint id PK + varchar user_id FK + bigint total_amount + varchar status + datetime created_at + } + + ORDER_ITEM { + bigint id PK + bigint order_id FK + bigint product_id FK + varchar product_name + bigint quantity + bigint price + } + + PAYMENT { + bigint id PK + bigint order_id FK + varchar status + varchar payment_request_id + datetime created_at + } + + %% ๊ด€๊ณ„ (cardinality) + USER ||--|| POINT : "1:1" + BRAND ||--o{ PRODUCT : "1:N" + PRODUCT ||--o{ LIKE : "1:N" + USER ||--o{ LIKE : "1:N" + USER ||--o{ ORDERS : "1:N" + ORDERS ||--o{ ORDER_ITEM : "1:N" + ORDER_ITEM }o--|| PRODUCT : "N:1" + ORDERS ||--|| PAYMENT : "1:1" +``` \ No newline at end of file diff --git a/docs/2round/2round.md b/docs/2round/2round.md new file mode 100644 index 000000000..84fdc982c --- /dev/null +++ b/docs/2round/2round.md @@ -0,0 +1,37 @@ +## โœ๏ธ Design Quest + +> **์ด์ปค๋จธ์Šค ๋„๋ฉ”์ธ(์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๋“ฑ)์— ๋Œ€ํ•œ ์„ค๊ณ„**๋ฅผ ์™„๋ฃŒํ•˜๊ณ , ๋‹ค์Œ ์ฃผ๋ถ€ํ„ฐ ๊ฐœ๋ฐœ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ค€์˜ ์„ค๊ณ„ ๋ฌธ์„œ๋ฅผ ์ •๋ฆฌํ•˜์—ฌ PR๋กœ ์ œ์ถœํ•ฉ๋‹ˆ๋‹ค. +> + +### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด + +- **์„ค๊ณ„ ๋ฒ”์œ„** + - ์ƒํ’ˆ ๋ชฉ๋ก / ์ƒํ’ˆ ์ƒ์„ธ / ๋ธŒ๋žœ๋“œ ์กฐํšŒ + - ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ (๋ฉฑ๋“ฑ ๋™์ž‘) + - ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ๊ฒฐ์ œ ํ๋ฆ„ (์žฌ๊ณ  ์ฐจ๊ฐ, ํฌ์ธํŠธ ์ฐจ๊ฐ, ์™ธ๋ถ€ ์‹œ์Šคํ…œ ์—ฐ๋™) +- **์ œ์™ธ ๋„๋ฉ”์ธ** + - ํšŒ์›๊ฐ€์ž…, ํฌ์ธํŠธ ์ถฉ์ „ (1์ฃผ์ฐจ ๊ตฌํ˜„ ์™„๋ฃŒ ๊ธฐ์ค€) +- **์š”๊ตฌ์‚ฌํ•ญ ๊ธฐ๋ฐ˜** + - ๋ฃจํ”„ํŒฉ ์ด์ปค๋จธ์Šค ์‹œ๋‚˜๋ฆฌ์˜ค ๋ฌธ์„œ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๋Šฅ/์ œ์•ฝ์‚ฌํ•ญ์„ ์„ค๊ณ„์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. +- **์ œ์ถœ ๋ฐฉ์‹** + 1. ์•„๋ž˜ ํŒŒ์ผ๋“ค์„ ํ”„๋กœ์ ํŠธ ๋‚ด `docs/week2/` ํด๋”์— `.md`๋กœ ์ €์žฅ + 2. Github PR๋กœ ์ œ์ถœ + - PR ์ œ๋ชฉ: `[2์ฃผ์ฐจ] ์„ค๊ณ„ ๋ฌธ์„œ ์ œ์ถœ - ํ™๊ธธ๋™` + - PR ๋ณธ๋ฌธ์— ๋ฆฌ๋ทฐ ํฌ์ธํŠธ ํฌํ•จ (์˜ˆ: ๊ณ ๋ฏผํ•œ ์ง€์  ๋“ฑ) + +### โœ… ์ œ์ถœ ํŒŒ์ผ ๋ชฉ๋ก (.docs/design ๋””๋ ‰ํ† ๋ฆฌ ๋‚ด) + +| ํŒŒ์ผ๋ช… | ๋‚ด์šฉ | +| --- | --- | +| `01-requirements.md` | ์œ ์ € ์‹œ๋‚˜๋ฆฌ์˜ค ๊ธฐ๋ฐ˜ ๊ธฐ๋Šฅ ์ •์˜, ์š”๊ตฌ์‚ฌํ•ญ ๋ช…์„ธ | +| `02-sequence-diagrams.md` | ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ ์ตœ์†Œ 2๊ฐœ ์ด์ƒ (Mermaid ๊ธฐ๋ฐ˜ ์ž‘์„ฑ ๊ถŒ์žฅ) | +| `03-class-diagram.md` | ๋„๋ฉ”์ธ ๊ฐ์ฒด ์„ค๊ณ„ (ํด๋ž˜์Šค ๋‹ค์ด์–ด๊ทธ๋žจ or ์„ค๋ช… ์ค‘์‹ฌ) | +| `04-erd.md` | ์ „์ฒด ํ…Œ์ด๋ธ” ๊ตฌ์กฐ ๋ฐ ๊ด€๊ณ„ ์ •๋ฆฌ (ERD Mermaid ์ž‘์„ฑ ๊ฐ€๋Šฅ) | + +## โœ… Checklist + +- [ ] ์ƒํ’ˆ/๋ธŒ๋žœ๋“œ/์ข‹์•„์š”/์ฃผ๋ฌธ ๋„๋ฉ”์ธ์ด ๋ชจ๋‘ ํฌํ•จ๋˜์–ด ์žˆ๋Š”๊ฐ€? +- [ ] ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ์ด ์œ ์ € ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ๋˜์–ด ์žˆ๋Š”๊ฐ€? +- [ ] ์‹œํ€€์Šค ๋‹ค์ด์–ด๊ทธ๋žจ์—์„œ ์ฑ…์ž„ ๊ฐ์ฒด๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š”๊ฐ€? +- [ ] ํด๋ž˜์Šค ๊ตฌ์กฐ๊ฐ€ ๋„๋ฉ”์ธ ์„ค๊ณ„๋ฅผ ์ž˜ ํ‘œํ˜„ํ•˜๊ณ  ์žˆ๋Š”๊ฐ€? +- [ ] ERD ์„ค๊ณ„ ์‹œ ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ์„ ๊ณ ๋ คํ•˜์—ฌ ๊ตฌ์„ฑํ•˜์˜€๋Š”๊ฐ€? \ No newline at end of file diff --git a/docs/3round/3round.md b/docs/3round/3round.md new file mode 100644 index 000000000..b9f333cca --- /dev/null +++ b/docs/3round/3round.md @@ -0,0 +1,60 @@ +# ๐Ÿ“ Round 3 Quests + +--- + +## ๐Ÿ’ป Implementation Quest + +> *** ๋„๋ฉ”์ธ ๋ชจ๋ธ๋ง**์„ ํ†ตํ•ด Product, Brand, Like, Order ๊ธฐ๋Šฅ์˜ ํ•ต์‹ฌ ๊ฐœ๋…์„ **Entity, Value Object, Domain Service ๋“ฑ ์ ํ•ฉํ•œ** **์ฝ”๋“œ**๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. +* ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ + DIP ๋ฅผ ์ ์šฉํ•ด ์œ ์—ฐํ•˜๊ณ  ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑํ•ฉ๋‹ˆ๋‹ค. +* **Application Layer๋ฅผ ๊ฒฝ๋Ÿ‰ ์ˆ˜์ค€**์œผ๋กœ ๊ตฌ์„ฑํ•˜์—ฌ, ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ์‹ค์ œ ๊ตฌํ˜„ํ•ด๋ด…๋‹ˆ๋‹ค. +* **๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑ**ํ•˜์—ฌ ๋„๋ฉ”์ธ ๋กœ์ง์˜ ์ •ํ•ฉ์„ฑ๊ณผ ๊ทœ์น™์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. +> + +### ๐Ÿ“‹ ๊ณผ์ œ ์ •๋ณด + +- ์ƒํ’ˆ, ๋ธŒ๋žœ๋“œ, ์ข‹์•„์š”, ์ฃผ๋ฌธ ๊ธฐ๋Šฅ์˜ **๋„๋ฉ”์ธ ๋ชจ๋ธ ๋ฐ ๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. +- ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ํ๋ฆ„์„ ์„ค๊ณ„ํ•˜๊ณ , ํ•„์š”ํ•œ ๋กœ์ง์„ **๋„๋ฉ”์ธ ์„œ๋น„์Šค**๋กœ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- Application Layer์—์„œ ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•˜๋Š” ํ๋ฆ„์„ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค. + (์˜ˆ: `ProductFacade.getProductDetail(productId)` โ†’ `Product + Brand + Like ์กฐํ•ฉ`) +- Repository Interface ์™€ ๊ตฌํ˜„์ฒด๋Š” ๋ถ„๋ฆฌํ•˜๊ณ , ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅ์„ฑ์„ ๊ณ ๋ คํ•œ ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ•ฉ๋‹ˆ๋‹ค. +- ๋ชจ๋“  ํ•ต์‹ฌ ๋„๋ฉ”์ธ ๋กœ์ง์— ๋Œ€ํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•˜๊ณ , ์˜ˆ์™ธ/๊ฒฝ๊ณ„ ์ผ€์ด์Šค๋„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + +### ๐Ÿท Product / Brand ๋„๋ฉ”์ธ + +## โœ… Checklist + +- [x] ์ƒํ’ˆ ์ •๋ณด ๊ฐ์ฒด๋Š” ๋ธŒ๋žœ๋“œ ์ •๋ณด, ์ข‹์•„์š” ์ˆ˜๋ฅผ ํฌํ•จํ•œ๋‹ค. +- [x] ์ƒํ’ˆ์˜ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`) ์„ ๊ณ ๋ คํ•œ ์กฐํšŒ ๊ธฐ๋Šฅ์„ ์„ค๊ณ„ํ–ˆ๋‹ค +- [x] ์ƒํ’ˆ์€ ์žฌ๊ณ ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๊ณ , ์ฃผ๋ฌธ ์‹œ ์ฐจ๊ฐํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค +- [x] ์žฌ๊ณ ๋Š” ๊ฐ์†Œ๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ์Œ์ˆ˜ ๋ฐฉ์ง€๋Š” ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ๋œ๋‹ค + +### ๐Ÿ‘ Like ๋„๋ฉ”์ธ + +- [x] ์ข‹์•„์š”๋Š” ์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„์˜ ๊ด€๊ณ„๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค +- [x] ์ค‘๋ณต ์ข‹์•„์š” ๋ฐฉ์ง€๋ฅผ ์œ„ํ•œ ๋ฉฑ๋“ฑ์„ฑ ์ฒ˜๋ฆฌ๊ฐ€ ๊ตฌํ˜„๋˜์—ˆ๋‹ค +- [x] ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก ์กฐํšŒ์—์„œ ํ•จ๊ป˜ ์ œ๊ณต๋œ๋‹ค +- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ/์ค‘๋ณต ๋ฐฉ์ง€ ํ๋ฆ„์„ ๊ฒ€์ฆํ–ˆ๋‹ค + +### ๐Ÿ›’ Order ๋„๋ฉ”์ธ + +- [x] ์ฃผ๋ฌธ์€ ์—ฌ๋Ÿฌ ์ƒํ’ˆ์„ ํฌํ•จํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ ์ˆ˜๋Ÿ‰์„ ๋ช…์‹œํ•œ๋‹ค +- [x] ์ฃผ๋ฌธ ์‹œ ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ฐจ๊ฐ, ์œ ์ € ํฌ์ธํŠธ ์ฐจ๊ฐ ๋“ฑ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค +- [x] ์žฌ๊ณ  ๋ถ€์กฑ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋“ฑ ์˜ˆ์™ธ ํ๋ฆ„์„ ๊ณ ๋ คํ•ด ์„ค๊ณ„๋˜์—ˆ๋‹ค +- [x] ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ ์ •์ƒ ์ฃผ๋ฌธ / ์˜ˆ์™ธ ์ฃผ๋ฌธ ํ๋ฆ„์„ ๋ชจ๋‘ ๊ฒ€์ฆํ–ˆ๋‹ค + +### ๐Ÿงฉ ๋„๋ฉ”์ธ ์„œ๋น„์Šค + +- [x] ๋„๋ฉ”์ธ ๊ฐ„ ํ˜‘๋ ฅ ๋กœ์ง์€ Domain Service์— ์œ„์น˜์‹œ์ผฐ๋‹ค +- [x] ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ Product + Brand ์ •๋ณด ์กฐํ•ฉ์€ ๋„๋ฉ”์ธ ์„œ๋น„์Šค์—์„œ ์ฒ˜๋ฆฌํ–ˆ๋‹ค +- [x] ๋ณตํ•ฉ ์œ ์Šค์ผ€์ด์Šค๋Š” Application Layer์— ์กด์žฌํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์€ ์œ„์ž„๋˜์—ˆ๋‹ค +- [x] ๋„๋ฉ”์ธ ์„œ๋น„์Šค๋Š” ์ƒํƒœ ์—†์ด, ๋„๋ฉ”์ธ ๊ฐ์ฒด์˜ ํ˜‘๋ ฅ ์ค‘์‹ฌ์œผ๋กœ ์„ค๊ณ„๋˜์—ˆ๋‹ค + +### **๐Ÿงฑ ์†Œํ”„ํŠธ์›จ์–ด ์•„ํ‚คํ…์ฒ˜ & ์„ค๊ณ„** + +- [x] ์ „์ฒด ํ”„๋กœ์ ํŠธ์˜ ๊ตฌ์„ฑ์€ ์•„๋ž˜ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค + - Application โ†’ **Domain** โ† Infrastructure +- [x] Application Layer๋Š” ๋„๋ฉ”์ธ ๊ฐ์ฒด๋ฅผ ์กฐํ•ฉํ•ด ํ๋ฆ„์„ orchestration ํ–ˆ๋‹ค +- [x] ํ•ต์‹ฌ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Entity, VO, Domain Service ์— ์œ„์น˜ํ•œ๋‹ค +- [x] Repository Interface๋Š” Domain Layer ์— ์ •์˜๋˜๊ณ , ๊ตฌํ˜„์ฒด๋Š” Infra์— ์œ„์น˜ํ•œ๋‹ค +- [x] ํŒจํ‚ค์ง€๋Š” ๊ณ„์ธต + ๋„๋ฉ”์ธ ๊ธฐ์ค€์œผ๋กœ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค (`/domain/order`, `/application/like` ๋“ฑ) +- [x] ํ…Œ์ŠคํŠธ๋Š” ์™ธ๋ถ€ ์˜์กด์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ , Fake/Stub ๋“ฑ์„ ์‚ฌ์šฉํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ๋˜์—ˆ๋‹ค \ No newline at end of file From 4ca321e6e8bf7b9d69231a23d46ab44a893e5d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Tue, 25 Nov 2025 11:28:12 +0900 Subject: [PATCH 064/164] =?UTF-8?q?round3:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 32 ++----- .../application/order/OrderItemRequest.java | 7 ++ .../application/order/OrderRequest.java | 8 ++ .../loopers/domain/order/OrderService.java | 20 ++++ .../interfaces/api/order/OrderV1ApiSpec.java | 4 +- .../api/order/OrderV1Controller.java | 3 +- .../interfaces/api/order/OrderV1Dto.java | 9 -- .../order/OrderFacadeIntegrationTest.java | 57 ++++++----- .../order/OrderServiceIntegrationTest.java | 28 +++--- .../interfaces/api/OrderV1ApiE2ETest.java | 96 ++++++++++--------- 10 files changed, 140 insertions(+), 124 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index e4725ada1..893428a79 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,7 +1,6 @@ package com.loopers.application.order; import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; import com.loopers.domain.point.PointService; import com.loopers.domain.product.Product; @@ -9,7 +8,6 @@ import com.loopers.domain.supply.SupplyService; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; -import com.loopers.interfaces.api.order.OrderV1Dto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -18,7 +16,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -31,6 +28,7 @@ public class OrderFacade { private final PointService pointService; private final SupplyService supplyService; + @Transactional(readOnly = true) public OrderInfo getOrderInfo(String userId, Long orderId) { User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); Order order = orderService.getOrderByIdAndUserId(orderId, user.getId()); @@ -46,38 +44,22 @@ public Page getOrderList(String userId, Pageable pageable) { } @Transactional - public OrderInfo createOrder(String userId, OrderV1Dto.OrderRequest request) { + public OrderInfo createOrder(String userId, OrderRequest request) { User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - // request์—์„œ productId - quantity ๋งต ์ƒ์„ฑ - Map productQuantityMap = request.items().stream() - .collect(Collectors.toMap( - OrderV1Dto.OrderRequest.OrderItemRequest::productId, - OrderV1Dto.OrderRequest.OrderItemRequest::quantity - )); + Map productIdQuantityMap = request.items().stream() + .collect(Collectors.toMap(OrderItemRequest::productId, OrderItemRequest::quantity)); - Map productMap = productService.getProductMapByIds(productQuantityMap.keySet()); + Map productMap = productService.getProductMapByIds(productIdQuantityMap.keySet()); request.items().forEach(item -> { supplyService.checkAndDecreaseStock(item.productId(), item.quantity()); }); - Integer totalAmount = productService.calculateTotalAmount(productQuantityMap); - + Integer totalAmount = productService.calculateTotalAmount(productIdQuantityMap); pointService.checkAndDeductPoint(user.getId(), totalAmount); - List orderItems = request.items() - .stream() - .map(item -> OrderItem.create( - item.productId(), - productMap.get(item.productId()).getName(), - item.quantity(), - productMap.get(item.productId()).getPrice() - )) - .toList(); - Order order = Order.create(user.getId(), orderItems); - - orderService.save(order); + Order order = orderService.createOrder(request.items(), productMap, user.getId()); return OrderInfo.from(order); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java new file mode 100644 index 000000000..cf4984f10 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java @@ -0,0 +1,7 @@ +package com.loopers.application.order; + +public record OrderItemRequest( + Long productId, + Integer quantity +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java new file mode 100644 index 000000000..63f746a8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java @@ -0,0 +1,8 @@ +package com.loopers.application.order; + +import java.util.List; + +public record OrderRequest( + List items +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 7628720f4..979d19fbd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,5 +1,7 @@ package com.loopers.domain.order; +import com.loopers.application.order.OrderItemRequest; +import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -9,6 +11,9 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.Map; + @RequiredArgsConstructor @Component public class OrderService { @@ -18,6 +23,21 @@ public Order save(Order order) { return orderRepository.save(order); } + public Order createOrder(List OrderItems, Map productMap, Long userId) { + List orderItems = OrderItems + .stream() + .map(item -> OrderItem.create( + item.productId(), + productMap.get(item.productId()).getName(), + item.quantity(), + productMap.get(item.productId()).getPrice() + )) + .toList(); + Order order = Order.create(userId, orderItems); + + return orderRepository.save(order); + } + public Order getOrderByIdAndUserId(Long orderId, Long userId) { return orderRepository.findByIdAndUserId(orderId, userId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 57197cb68..37259802d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -1,10 +1,10 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.RequestHeader; @@ -23,7 +23,7 @@ ApiResponse createOrder( name = "์ฃผ๋ฌธ ์š”์ฒญ ์ •๋ณด", description = "์ฃผ๋ฌธ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด" ) - OrderV1Dto.OrderRequest request + OrderRequest request ); // /api/v1/orders - GET diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index ae8f75ae3..0daeabd69 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -2,6 +2,7 @@ import com.loopers.application.order.OrderFacade; import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderRequest; import com.loopers.interfaces.api.ApiResponse; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -22,7 +23,7 @@ public class OrderV1Controller implements OrderV1ApiSpec { @Override public ApiResponse createOrder( @RequestHeader(value = "X-USER-ID", required = false) String userId, - @RequestBody OrderV1Dto.OrderRequest request + @RequestBody OrderRequest request ) { if (StringUtils.isBlank(userId)) { throw new CoreException(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 01b97d12e..9b5312231 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -7,15 +7,6 @@ import java.util.List; public class OrderV1Dto { - public record OrderRequest( - List items - ) { - public record OrderItemRequest( - Long productId, - Integer quantity - ) { - } - } public record OrderResponse( Long orderId, diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 7adbb731d..280d05573 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -14,7 +14,6 @@ import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.infrastructure.supply.SupplyJpaRepository; import com.loopers.infrastructure.user.UserJpaRepository; -import com.loopers.interfaces.api.order.OrderV1Dto; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -128,10 +127,10 @@ class CreateOrder { @Test void should_createOrder_when_validRequest() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 1) + new OrderItemRequest(productId1, 2), + new OrderItemRequest(productId2, 1) ) ); @@ -150,9 +149,9 @@ void should_createOrder_when_validRequest() { void should_throwException_when_productIdDoesNotExist() { // arrange Long nonExistentProductId = 99999L; - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(nonExistentProductId, 1) + new OrderItemRequest(nonExistentProductId, 1) ) ); @@ -166,9 +165,9 @@ void should_throwException_when_productIdDoesNotExist() { @Test void should_throwException_when_singleProductStockInsufficient() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999) + new OrderItemRequest(productId1, 99999) ) ); @@ -184,10 +183,10 @@ void should_throwException_when_partialStockInsufficient() { // arrange // productId1: ์žฌ๊ณ  100, productId2: ์žฌ๊ณ  50 // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ + new OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ + new OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ ) ); @@ -203,10 +202,10 @@ void should_throwException_when_partialStockInsufficient() { @Test void should_throwException_when_allProductsStockInsufficient() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999), - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) + new OrderItemRequest(productId1, 99999), + new OrderItemRequest(productId2, 99999) ) ); @@ -228,9 +227,9 @@ void should_throwException_when_supplyDoesNotExist() { productMetricsJpaRepository.save(metrics); // Supply๋Š” ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(savedProduct.getId(), 1) + new OrderItemRequest(savedProduct.getId(), 1) ) ); @@ -245,17 +244,17 @@ void should_throwException_when_supplyDoesNotExist() { void should_throwException_when_pointInsufficient() { // arrange // ํฌ์ธํŠธ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉ - OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + OrderRequest firstOrder = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10) + new OrderItemRequest(productId1, 10) ) ); orderFacade.createOrder(userId, firstOrder); // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 1) // 20000์› ํ•„์š” (๋ถ€์กฑ) + new OrderItemRequest(productId2, 1) // 20000์› ํ•„์š” (๋ถ€์กฑ) ) ); @@ -271,18 +270,18 @@ void should_throwException_when_pointInsufficient() { void should_createOrder_when_pointExactlyMatches() { // arrange // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ - OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + OrderRequest firstOrder = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ + new OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ ) ); orderFacade.createOrder(userId, firstOrder); // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์ฃผ๋ฌธ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› + new OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› ) ); @@ -300,10 +299,10 @@ void should_throwException_when_duplicateProducts() { // arrange // ๊ฐ™์€ ์ƒํ’ˆ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ํฌํ•จ // Note: Collectors.toMap()์€ ์ค‘๋ณต ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด IllegalStateException์„ ๋ฐœ์ƒ์‹œํ‚ด - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 3) // ์ค‘๋ณต + new OrderItemRequest(productId1, 2), + new OrderItemRequest(productId1, 3) // ์ค‘๋ณต ) ); @@ -320,9 +319,9 @@ void should_throwException_when_duplicateProducts() { void should_throwException_when_userDoesNotExist() { // arrange String nonExistentUserId = "nonexist"; - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java index 1e524bdf0..b3c481603 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -1,6 +1,8 @@ package com.loopers.domain.order; +import com.loopers.application.order.OrderItemRequest; import com.loopers.domain.common.vo.Price; +import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; @@ -12,6 +14,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -39,15 +42,17 @@ class SaveOrder { void should_saveOrder_when_validOrder() { // arrange Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), - OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)) + List orderItemRequests = List.of( + new OrderItemRequest(1L, 2), + new OrderItemRequest(2L, 1) + ); + Map productMap = Map.of( + 1L, Product.create("์ƒํ’ˆ1", 1L, new Price(10000)), + 2L, Product.create("์ƒํ’ˆ2", 1L, new Price(20000)) ); - Order order = Order.create(userId, orderItems); -// when(spyOrderRepository.save(any(Order.class))).thenReturn(order); // act - Order result = orderService.save(order); + Order result = orderService.createOrder(orderItemRequests, productMap, userId); // assert verify(spyOrderRepository).save(any(Order.class)); @@ -61,14 +66,15 @@ void should_saveOrder_when_validOrder() { void should_saveOrder_when_singleOrderItem() { // arrange Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) + List orderItemRequests = List.of( + new OrderItemRequest(1L, 1) + ); + Map productMap = Map.of( + 1L, Product.create("์ƒํ’ˆ1", 1L, new Price(15000)) ); - Order order = Order.create(userId, orderItems); -// when(spyOrderRepository.save(any(Order.class))).thenReturn(order); // act - Order result = orderService.save(order); + Order result = orderService.createOrder(orderItemRequests, productMap, userId); // assert verify(spyOrderRepository).save(any(Order.class)); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java index cbfaf772d..48e79e710 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api; +import com.loopers.application.order.OrderItemRequest; +import com.loopers.application.order.OrderRequest; import com.loopers.domain.brand.Brand; import com.loopers.domain.common.vo.Price; import com.loopers.domain.metrics.product.ProductMetrics; @@ -155,10 +157,10 @@ class PostOrder { @Test void returnOrderInfo_whenCreateOrderSuccess() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 1) + new OrderItemRequest(productId1, 2), + new OrderItemRequest(productId2, 1) ) ); HttpHeaders headers = createHeaders(); @@ -184,9 +186,9 @@ void returnOrderInfo_whenCreateOrderSuccess() { @Test void returnBadRequest_whenStockInsufficient() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999) + new OrderItemRequest(productId1, 99999) ) ); HttpHeaders headers = createHeaders(); @@ -206,9 +208,9 @@ void returnBadRequest_whenStockInsufficient() { void returnNotFoundOrBadRequest_whenProductIdDoesNotExist() { // arrange Long nonExistentProductId = 99999L; - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(nonExistentProductId, 1) + new OrderItemRequest(nonExistentProductId, 1) ) ); HttpHeaders headers = createHeaders(); @@ -232,9 +234,9 @@ void returnBadRequest_whenPointInsufficient() { // arrange // ํฌ์ธํŠธ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉ HttpHeaders headers = createHeaders(); - OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + OrderRequest firstOrder = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10) + new OrderItemRequest(productId1, 10) ) ); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { @@ -242,9 +244,9 @@ void returnBadRequest_whenPointInsufficient() { testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) + new OrderItemRequest(productId2, 99999) ) ); @@ -260,9 +262,9 @@ void returnBadRequest_whenPointInsufficient() { @Test void returnBadRequest_whenXUserIdHeaderIsMissing() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); @@ -280,9 +282,9 @@ void returnBadRequest_whenXUserIdHeaderIsMissing() { @Test void returnBadRequest_whenXUserIdHeaderIsEmpty() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); HttpHeaders headers = new HttpHeaders(); @@ -302,9 +304,9 @@ void returnBadRequest_whenXUserIdHeaderIsEmpty() { @Test void returnBadRequest_whenXUserIdHeaderIsBlank() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); HttpHeaders headers = new HttpHeaders(); @@ -324,9 +326,9 @@ void returnBadRequest_whenXUserIdHeaderIsBlank() { @Test void returnNotFound_whenUserIdDoesNotExist() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); HttpHeaders headers = new HttpHeaders(); @@ -347,9 +349,9 @@ void returnNotFound_whenUserIdDoesNotExist() { void returnNotFound_whenProductIdDoesNotExist() { // arrange Long nonExistentProductId = 99999L; - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(nonExistentProductId, 1) + new OrderItemRequest(nonExistentProductId, 1) ) ); HttpHeaders headers = createHeaders(); @@ -370,10 +372,10 @@ void returnBadRequest_whenPartialStockInsufficient() { // arrange // productId1: ์žฌ๊ณ  100, productId2: ์žฌ๊ณ  50 // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ + new OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ + new OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ ) ); HttpHeaders headers = createHeaders(); @@ -394,10 +396,10 @@ void returnBadRequest_whenPartialStockInsufficient() { @Test void returnBadRequest_whenAllProductsStockInsufficient() { // arrange - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 99999), - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) + new OrderItemRequest(productId1, 99999), + new OrderItemRequest(productId2, 99999) ) ); HttpHeaders headers = createHeaders(); @@ -419,9 +421,9 @@ void returnOrderInfo_whenPointExactlyMatches() { // arrange // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ HttpHeaders headers = createHeaders(); - OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + OrderRequest firstOrder = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ + new OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ ) ); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { @@ -430,9 +432,9 @@ void returnOrderInfo_whenPointExactlyMatches() { // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์ฃผ๋ฌธ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› + new OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› ) ); @@ -455,10 +457,10 @@ void returnError_whenDuplicateProducts() { // arrange // ๊ฐ™์€ ์ƒํ’ˆ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ํฌํ•จ // Note: Collectors.toMap()์€ ์ค‘๋ณต ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด IllegalStateException์„ ๋ฐœ์ƒ์‹œํ‚ด - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2), - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 3) // ์ค‘๋ณต + new OrderItemRequest(productId1, 2), + new OrderItemRequest(productId1, 3) // ์ค‘๋ณต ) ); HttpHeaders headers = createHeaders(); @@ -496,9 +498,9 @@ void returnNotFoundAndRollbackStock_whenPointDoesNotExist() { Supply initialSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); int initialStock = initialSupply.getStock().quantity(); - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); HttpHeaders headers = new HttpHeaders(); @@ -529,9 +531,9 @@ void should_rollbackStock_whenPointInsufficientAfterStockDecrease() { // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ HttpHeaders headers = createHeaders(); - OrderV1Dto.OrderRequest firstOrder = new OrderV1Dto.OrderRequest( + OrderRequest firstOrder = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ + new OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ ) ); ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { @@ -540,9 +542,9 @@ void should_rollbackStock_whenPointInsufficientAfterStockDecrease() { // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ (์žฌ๊ณ ๋Š” ์ถฉ๋ถ„) - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 2) // 20000์› ํ•„์š” (๋ถ€์กฑ) + new OrderItemRequest(productId1, 2) // 20000์› ํ•„์š” (๋ถ€์กฑ) ) ); @@ -570,10 +572,10 @@ void should_rollbackStock_whenPartialStockInsufficient() { int initialStock2 = initialSupply2.getStock().quantity(); // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ - OrderV1Dto.OrderRequest request = new OrderV1Dto.OrderRequest( + OrderRequest request = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ - new OrderV1Dto.OrderRequest.OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ + new OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ + new OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ ) ); HttpHeaders headers = createHeaders(); @@ -606,9 +608,9 @@ void returnOrderList_whenGetOrderListSuccess() { // arrange HttpHeaders headers = createHeaders(); // ์ฃผ๋ฌธ ์ƒ์„ฑ - OrderV1Dto.OrderRequest orderRequest = new OrderV1Dto.OrderRequest( + OrderRequest orderRequest = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); ParameterizedTypeReference> orderResponseType = new ParameterizedTypeReference<>() { @@ -702,9 +704,9 @@ class GetOrderDetail { void setupOrder() { // ์ฃผ๋ฌธ ์ƒ์„ฑ HttpHeaders headers = createHeaders(); - OrderV1Dto.OrderRequest orderRequest = new OrderV1Dto.OrderRequest( + OrderRequest orderRequest = new OrderRequest( List.of( - new OrderV1Dto.OrderRequest.OrderItemRequest(productId1, 1) + new OrderItemRequest(productId1, 1) ) ); ParameterizedTypeReference> orderResponseType = new ParameterizedTypeReference<>() { From d321b49deaa3e09f3e69d9b7b98c2d4f790a774c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=B0=B0?= Date: Fri, 28 Nov 2025 15:48:32 +0900 Subject: [PATCH 065/164] Revert "Round3: Product, Brand, Like, Order" --- apps/commerce-api/build.gradle.kts | 4 +- .../com/loopers/CommerceApiApplication.java | 2 - .../like/product/LikeProductFacade.java | 83 -- .../like/product/LikeProductInfo.java | 11 - .../application/order/OrderFacade.java | 66 -- .../loopers/application/order/OrderInfo.java | 21 - .../application/order/OrderItemInfo.java | 27 - .../application/order/OrderItemRequest.java | 7 - .../application/order/OrderRequest.java | 8 - .../application/point/PointFacade.java | 32 - .../application/product/ProductFacade.java | 128 --- .../application/product/ProductInfo.java | 11 - .../loopers/application/user/UserFacade.java | 31 - .../loopers/application/user/UserInfo.java | 14 - .../java/com/loopers/domain/brand/Brand.java | 30 - .../loopers/domain/brand/BrandRepository.java | 10 - .../loopers/domain/brand/BrandService.java | 26 - .../com/loopers/domain/common/vo/Price.java | 26 - .../domain/like/product/LikeProduct.java | 37 - .../like/product/LikeProductRepository.java | 16 - .../like/product/LikeProductService.java | 31 - .../metrics/product/ProductMetrics.java | 33 - .../product/ProductMetricsRepository.java | 15 - .../product/ProductMetricsService.java | 39 - .../java/com/loopers/domain/order/Order.java | 44 - .../com/loopers/domain/order/OrderItem.java | 50 -- .../loopers/domain/order/OrderRepository.java | 14 - .../loopers/domain/order/OrderService.java | 52 -- .../java/com/loopers/domain/point/Point.java | 48 - .../loopers/domain/point/PointRepository.java | 9 - .../loopers/domain/point/PointService.java | 44 - .../com/loopers/domain/product/Product.java | 46 - .../domain/product/ProductRepository.java | 18 - .../domain/product/ProductService.java | 55 -- .../com/loopers/domain/supply/Supply.java | 43 - .../domain/supply/SupplyRepository.java | 15 - .../loopers/domain/supply/SupplyService.java | 40 - .../com/loopers/domain/supply/vo/Stock.java | 40 - .../java/com/loopers/domain/user/User.java | 52 -- .../loopers/domain/user/UserRepository.java | 13 - .../com/loopers/domain/user/UserService.java | 37 - .../brand/BrandJpaRepository.java | 8 - .../brand/BrandRepositoryImpl.java | 25 - .../like/LikeProductJpaRepository.java | 17 - .../like/LikeProductRepositoryImpl.java | 37 - .../product/ProductMetricsJpaRepository.java | 10 - .../product/ProductMetricsRepositoryImpl.java | 32 - .../order/OrderJpaRepository.java | 16 - .../order/OrderRepositoryImpl.java | 31 - .../point/PointJpaRepository.java | 11 - .../point/PointRepositoryImpl.java | 24 - .../product/ProductJpaRepository.java | 7 - .../product/ProductRepositoryImpl.java | 38 - .../supply/SupplyJpaRepository.java | 21 - .../supply/SupplyRepositoryImpl.java | 36 - .../user/UserJpaRepository.java | 19 - .../user/UserRepositoryImpl.java | 34 - .../like/product/LikeProductV1ApiSpec.java | 49 -- .../like/product/LikeProductV1Controller.java | 59 -- .../api/like/product/LikeProductV1Dto.java | 48 - .../interfaces/api/order/OrderV1ApiSpec.java | 54 -- .../api/order/OrderV1Controller.java | 61 -- .../interfaces/api/order/OrderV1Dto.java | 64 -- .../interfaces/api/point/PointV1ApiSpec.java | 44 - .../api/point/PointV1Controller.java | 41 - .../interfaces/api/point/PointV1Dto.java | 19 - .../api/product/ProductV1ApiSpec.java | 40 - .../api/product/ProductV1Controller.java | 39 - .../interfaces/api/product/ProductV1Dto.java | 46 - .../interfaces/api/user/UserV1ApiSpec.java | 37 - .../interfaces/api/user/UserV1Controller.java | 41 - .../interfaces/api/user/UserV1Dto.java | 27 - .../src/main/resources/application.yml | 2 +- .../order/OrderFacadeIntegrationTest.java | 339 ------- .../com/loopers/domain/brand/BrandTest.java | 127 --- .../loopers/domain/common/vo/PriceTest.java | 62 -- .../LikeProductServiceIntegrationTest.java | 232 ----- .../domain/like/product/LikeProductTest.java | 202 ----- .../loopers/domain/order/OrderItemTest.java | 276 ------ .../order/OrderServiceIntegrationTest.java | 151 ---- .../com/loopers/domain/order/OrderTest.java | 176 ---- .../loopers/domain/point/PointModelTest.java | 30 - .../point/PointServiceIntegrationTest.java | 78 -- .../ProductServiceIntegrationTest.java | 362 -------- .../com/loopers/domain/supply/SupplyTest.java | 130 --- .../loopers/domain/supply/vo/StockTest.java | 183 ---- .../loopers/domain/user/UserModelTest.java | 139 --- .../user/UserServiceIntegrationTest.java | 108 --- .../api/LikeProductV1ApiE2ETest.java | 485 ---------- .../interfaces/api/OrderV1ApiE2ETest.java | 830 ------------------ .../interfaces/api/PointV1ApiE2ETest.java | 246 ------ .../interfaces/api/ProductV1ApiE2ETest.java | 265 ------ .../interfaces/api/UserV1ApiE2ETest.java | 202 ----- docker/infra-compose.yml | 174 ++-- settings.gradle.kts | 6 +- 95 files changed, 93 insertions(+), 7075 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/User.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index e6d28d4ed..03ce68f02 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -1,7 +1,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) -// implementation(project(":modules:redis")) + implementation(project(":modules:redis")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -18,5 +18,5 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) -// testImplementation(testFixtures(project(":modules:redis"))) + testImplementation(testFixtures(project(":modules:redis"))) } diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 62efd22b3..9027b51bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,8 +4,6 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; - import java.util.TimeZone; @ConfigurationPropertiesScan diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java deleted file mode 100644 index c70fa18fa..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductFacade.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.loopers.application.like.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.product.LikeProduct; -import com.loopers.domain.like.product.LikeProductService; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.metrics.product.ProductMetricsService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.SupplyService; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class LikeProductFacade { - private final LikeProductService likeProductService; - private final UserService userService; - private final ProductService productService; - private final ProductMetricsService productMetricsService; - private final BrandService brandService; - private final SupplyService supplyService; - - public void likeProduct(String userId, Long productId) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - if (!productService.existsById(productId)) { - throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - likeProductService.likeProduct(user.getId(), productId); - } - - public void unlikeProduct(String userId, Long productId) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - if (!productService.existsById(productId)) { - throw new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - likeProductService.unlikeProduct(user.getId(), productId); - } - - public Page getLikedProducts(String userId, Pageable pageable) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - Page likedProducts = likeProductService.getLikedProducts(user.getId(), pageable); - - List productIds = likedProducts.map(LikeProduct::getProductId).toList(); - Map productMap = productService.getProductMapByIds(productIds); - - Set brandIds = productMap.values().stream().map(Product::getBrandId).collect(Collectors.toSet()); - - Map metricsMap = productMetricsService.getMetricsMapByProductIds(productIds); - Map supplyMap = supplyService.getSupplyMapByProductIds(productIds); - Map brandMap = brandService.getBrandMapByBrandIds(brandIds); - - return likedProducts.map(likeProduct -> { - Product product = productMap.get(likeProduct.getProductId()); - ProductMetrics metrics = metricsMap.get(product.getId()); - Brand brand = brandMap.get(product.getBrandId()); - Supply supply = supplyMap.get(product.getId()); - - return new LikeProductInfo( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice().amount(), - metrics.getLikeCount(), - supply.getStock().quantity() - ); - }); - - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java deleted file mode 100644 index ce8b4928c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/product/LikeProductInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.application.like.product; - -public record LikeProductInfo( - Long id, - String name, - String brand, - int price, - int likes, - int stock -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java deleted file mode 100644 index 893428a79..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.point.PointService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.domain.supply.SupplyService; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Map; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class OrderFacade { - private final UserService userService; - private final OrderService orderService; - private final ProductService productService; - private final PointService pointService; - private final SupplyService supplyService; - - @Transactional(readOnly = true) - public OrderInfo getOrderInfo(String userId, Long orderId) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - Order order = orderService.getOrderByIdAndUserId(orderId, user.getId()); - - return OrderInfo.from(order); - } - - @Transactional(readOnly = true) - public Page getOrderList(String userId, Pageable pageable) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - Page orders = orderService.getOrdersByUserId(user.getId(), pageable); - return orders.map(OrderInfo::from); - } - - @Transactional - public OrderInfo createOrder(String userId, OrderRequest request) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - Map productIdQuantityMap = request.items().stream() - .collect(Collectors.toMap(OrderItemRequest::productId, OrderItemRequest::quantity)); - - Map productMap = productService.getProductMapByIds(productIdQuantityMap.keySet()); - - request.items().forEach(item -> { - supplyService.checkAndDecreaseStock(item.productId(), item.quantity()); - }); - - Integer totalAmount = productService.calculateTotalAmount(productIdQuantityMap); - pointService.checkAndDeductPoint(user.getId(), totalAmount); - - Order order = orderService.createOrder(request.items(), productMap, user.getId()); - - return OrderInfo.from(order); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java deleted file mode 100644 index c75047e66..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; - -import java.util.List; - -public record OrderInfo( - Long orderId, - Long userId, - Integer totalPrice, - List items -) { - public static OrderInfo from(Order order) { - return new OrderInfo( - order.getId(), - order.getUserId(), - order.getTotalPrice().amount(), - OrderItemInfo.fromList(order.getOrderItems()) - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java deleted file mode 100644 index 99c53a78e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.OrderItem; - -import java.util.List; - -public record OrderItemInfo( - Long productId, - String productName, - Integer quantity, - Integer totalPrice -) { - public static OrderItemInfo from(OrderItem orderItem) { - return new OrderItemInfo( - orderItem.getProductId(), - orderItem.getProductName(), - orderItem.getQuantity(), - orderItem.getTotalPrice() - ); - } - - public static List fromList(List items) { - return items.stream() - .map(OrderItemInfo::from) - .toList(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java deleted file mode 100644 index cf4984f10..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.application.order; - -public record OrderItemRequest( - Long productId, - Integer quantity -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java deleted file mode 100644 index 63f746a8f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.application.order; - -import java.util.List; - -public record OrderRequest( - List items -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java deleted file mode 100644 index 126a528de..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.point; - -import com.loopers.domain.point.PointService; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class PointFacade { - private final PointService pointService; - private final UserService userService; - - @Transactional(readOnly = true) - public Long getCurrentPoint(String userId) { - User user = userService.findByUserId(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - return pointService.getCurrentPoint(user.getId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } - - @Transactional - public Long chargePoint(String userId, int amount) { - User user = userService.findByUserIdForUpdate(userId).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - - return pointService.chargePoint(user.getId(), amount); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java deleted file mode 100644 index b87280ca3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.metrics.product.ProductMetricsService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.SupplyService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class ProductFacade { - private final ProductService productService; - private final ProductMetricsService productMetricsService; - private final BrandService brandService; - private final SupplyService supplyService; - - @Transactional(readOnly = true) - public Page getProductList(Pageable pageable) { - String sortStr = pageable.getSort().toString().split(":")[0]; - if (StringUtils.equals(sortStr, "like_desc")) { - int page = pageable.getPageNumber(); - int size = pageable.getPageSize(); - Sort sort = Sort.by(Sort.Direction.DESC, "likeCount"); - return getProductsByLikeCount(PageRequest.of(page, size, sort)); - } - - Page products = productService.getProducts(pageable); - - List productIds = products.map(Product::getId).toList(); - Set brandIds = products.map(Product::getBrandId).toSet(); - - Map metricsMap = productMetricsService.getMetricsMapByProductIds(productIds); - Map supplyMap = supplyService.getSupplyMapByProductIds(productIds); - Map brandMap = brandService.getBrandMapByBrandIds(brandIds); - - return products.map(product -> { - ProductMetrics metrics = metricsMap.get(product.getId()); - if (metrics == null) { - throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ฉ”ํŠธ๋ฆญ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - Supply supply = supplyMap.get(product.getId()); - if (supply == null) { - throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - return new ProductInfo( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice().amount(), - metrics.getLikeCount(), - supply.getStock().quantity() - ); - }); - } - - public Page getProductsByLikeCount(Pageable pageable) { - Page metricsPage = productMetricsService.getMetrics(pageable); - List productIds = metricsPage.map(ProductMetrics::getProductId).toList(); - Map productMap = productService.getProductMapByIds(productIds); - Set brandIds = productMap.values().stream().map(Product::getBrandId).collect(Collectors.toSet()); - Map brandMap = brandService.getBrandMapByBrandIds(brandIds); - Map supplyMap = supplyService.getSupplyMapByProductIds(productIds); - - return metricsPage.map(metrics -> { - Product product = productMap.get(metrics.getProductId()); - if (product == null) { - throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - Supply supply = supplyMap.get(product.getId()); - if (supply == null) { - throw new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - return new ProductInfo( - product.getId(), - product.getName(), - brand.getName(), - product.getPrice().amount(), - metrics.getLikeCount(), - supply.getStock().quantity() - ); - }); - } - - @Transactional(readOnly = true) - public ProductInfo getProductDetail(Long productId) { - Product product = productService.getProductById(productId); - ProductMetrics metrics = productMetricsService.getMetricsByProductId(productId); - Brand brand = brandService.getBrandById(product.getBrandId()); - Supply supply = supplyService.getSupplyByProductId(productId); - - return new ProductInfo( - productId, - product.getName(), - brand.getName(), - product.getPrice().amount(), - metrics.getLikeCount(), - supply.getStock().quantity() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java deleted file mode 100644 index b5286ed99..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.application.product; - -public record ProductInfo( - Long id, - String name, - String brand, - int price, - int likes, - int stock -) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java deleted file mode 100644 index 030d014b6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.point.PointService; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class UserFacade { - private final UserService userService; - private final PointService pointService; - - @Transactional - public UserInfo registerUser(String userId, String email, String birthday, String gender) { - User registeredUser = userService.registerUser(userId, email, birthday, gender); - pointService.createPoint(registeredUser.getId()); - return UserInfo.from(registeredUser); - } - - public UserInfo getUserInfo(String userId) { - Optional user = userService.findByUserId(userId); - return UserInfo.from(user.orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."))); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java deleted file mode 100644 index 84cda840f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; - -public record UserInfo(String id, String email, String birthday, String gender) { - public static UserInfo from(User user) { - return new UserInfo( - user.getUserId(), - user.getEmail(), - user.getBirthday(), - user.getGender() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java deleted file mode 100644 index a55ccbd33..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ /dev/null @@ -1,30 +0,0 @@ - package com.loopers.domain.brand; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; - -@Entity -@Table(name = "tb_brand") -@Getter -public class Brand extends BaseEntity { - private String name; - - protected Brand() { - } - - private Brand(String name) { - this.name = name; - } - - public static Brand create(String name) { - if (StringUtils.isBlank(name)) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return new Brand(name); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java deleted file mode 100644 index c51be9399..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.domain.brand; - -import java.util.Collection; -import java.util.Optional; - -public interface BrandRepository { - Optional findById(Long id); - - Collection findAllByIdIn(Collection ids); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java deleted file mode 100644 index 3fba0f915..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.Map; - -@RequiredArgsConstructor -@Component -public class BrandService { - private final BrandRepository brandRepository; - - public Brand getBrandById(Long brandId) { - return brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } - - public Map getBrandMapByBrandIds(Collection brandIds) { - return brandRepository.findAllByIdIn(brandIds) - .stream() - .collect(java.util.stream.Collectors.toMap(Brand::getId, brand -> brand)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java deleted file mode 100644 index 58eee0043..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/vo/Price.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.loopers.domain.common.vo; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeConverter; - -public record Price(int amount) { - public Price { - if (amount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - } - - public static class Converter implements AttributeConverter { - - @Override - public Integer convertToDatabaseColumn(Price attribute) { - return attribute.amount(); - } - - @Override - public Price convertToEntityAttribute(Integer dbData) { - return new Price(dbData); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java deleted file mode 100644 index ee8d09c49..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProduct.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.loopers.domain.like.product; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; - -@Entity -@Table(name = "tb_like_product") -@Getter -public class LikeProduct extends BaseEntity { - @Column(name = "user_id", nullable = false, updatable = false) - private Long userId; - @Column(name = "product_id", nullable = false, updatable = false) - private Long productId; - - protected LikeProduct() { - } - - private LikeProduct(Long userId, Long productId) { - this.userId = userId; - this.productId = productId; - } - - public static LikeProduct create(Long userId, Long productId) { - if (userId == null || userId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return new LikeProduct(userId, productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java deleted file mode 100644 index 525176f10..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.loopers.domain.like.product; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.Optional; - -public interface LikeProductRepository { - boolean existsByUserIdAndProductId(Long userId, Long productId); - - Optional findByUserIdAndProductId(Long userId, Long productId); - - void save(LikeProduct likeProduct); - - Page getLikeProductsByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java deleted file mode 100644 index f9cacef65..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/product/LikeProductService.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.domain.like.product; - -import com.loopers.domain.BaseEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; - -@RequiredArgsConstructor -@Service -public class LikeProductService { - private final LikeProductRepository likeProductRepository; - - public void likeProduct(Long userId, Long productId) { - likeProductRepository.findByUserIdAndProductId(userId, productId) - .ifPresentOrElse(BaseEntity::restore, () -> { - LikeProduct likeProduct = LikeProduct.create(userId, productId); - likeProductRepository.save(likeProduct); - }); - } - - public void unlikeProduct(Long userId, Long productId) { - likeProductRepository.findByUserIdAndProductId(userId, productId) - .ifPresent(BaseEntity::delete); - } - - public Page getLikedProducts(Long userId, Pageable pageable) { - return likeProductRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, PageRequest.of(pageable.getPageNumber(), pageable.getPageSize())); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java deleted file mode 100644 index d815fd878..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetrics.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.loopers.domain.metrics.product; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; - -@Entity -@Table(name = "tb_product_metrics") -@Getter -public class ProductMetrics extends BaseEntity { - // ํ˜„์žฌ๋Š” ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋งŒ ๊ด€๋ฆฌํ•˜์ง€๋งŒ, ์ถ”ํ›„์— ๋‹ค๋ฅธ ๋ฉ”ํŠธ๋ฆญ๋“ค๋„ ์ถ”๊ฐ€๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. - private Long productId; - private Integer likeCount; - - protected ProductMetrics() { - } - - public static ProductMetrics create(Long productId, Integer likeCount) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (likeCount == null || likeCount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š” ์ˆ˜๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - ProductMetrics metrics = new ProductMetrics(); - metrics.productId = productId; - metrics.likeCount = likeCount; - return metrics; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java deleted file mode 100644 index c9f236182..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.domain.metrics.product; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.Collection; -import java.util.Optional; - -public interface ProductMetricsRepository { - Optional findByProductId(Long productId); - - Collection findByProductIds(Collection productIds); - - Page findAll(Pageable pageable); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java b/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java deleted file mode 100644 index d39f95c9f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/metrics/product/ProductMetricsService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.domain.metrics.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.Map; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class ProductMetricsService { - private final ProductMetricsRepository productMetricsRepository; - - public ProductMetrics getMetricsByProductId(Long productId) { - return productMetricsRepository.findByProductId(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ๋ฉ”ํŠธ๋ฆญ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } - - public Map getMetricsMapByProductIds(Collection productIds) { - return productMetricsRepository.findByProductIds(productIds) - .stream() - .collect(Collectors.toMap(ProductMetrics::getProductId, metrics -> metrics)); - } - - // pageable like_count ์š”๊ฑด์— ๋”ฐ๋ผ ์ •๋ ฌ๋œ ์ƒ์œ„ N๊ฐœ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์กฐํšŒ - public Page getMetrics(Pageable pageable) { - // ํ˜„์žฌ๋Š” like_count, desc๋งŒ ๊ฐ€์ง€๋ฏ€๋กœ, ์˜ˆ์™ธ์ฒ˜๋ฆฌ ํ•„์š” - String sortString = pageable.getSort().toString(); - if (!sortString.equals("likeCount: DESC")) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ •๋ ฌ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค."); - } - return productMetricsRepository.findAll(pageable); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java deleted file mode 100644 index c9af5dba3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.BaseEntity; -import com.loopers.domain.common.vo.Price; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.*; -import lombok.Getter; - -import java.util.List; - -@Entity -@Table(name = "tb_order") -@Getter -public class Order extends BaseEntity { - private Long userId; - @ElementCollection - @CollectionTable( - name = "tb_order_item", - joinColumns = @JoinColumn(name = "order_id") - ) - private List orderItems; - @Convert(converter = Price.Converter.class) - private Price totalPrice; - - protected Order() { - } - - private Order(Long userId, List orderItems) { - this.userId = userId; - this.orderItems = orderItems; - this.totalPrice = new Price(orderItems.stream().map(OrderItem::getTotalPrice).reduce(Math::addExact).get()); - } - - public static Order create(Long userId, List orderItems) { - if (userId == null || userId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (orderItems == null || orderItems.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - return new Order(userId, orderItems); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java deleted file mode 100644 index e4a4eaa3f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.common.vo.Price; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Convert; -import jakarta.persistence.Embeddable; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; - -@Embeddable -@Getter -public class OrderItem { - private Long productId; - private String productName; - private Integer quantity; - @Convert(converter = Price.Converter.class) - private Price price; - - public Integer getTotalPrice() { - return this.price.amount() * this.quantity; - } - - protected OrderItem() { - } - - private OrderItem(Long productId, String productName, Integer quantity, Price price) { - this.productId = productId; - this.productName = productName; - this.quantity = quantity; - this.price = price; - } - - public static OrderItem create(Long productId, String productName, Integer quantity, Price price) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (StringUtils.isBlank(productName)) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (quantity == null || quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (price == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - - return new OrderItem(productId, productName, quantity, price); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java deleted file mode 100644 index 0118aa719..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.domain.order; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.Optional; - -public interface OrderRepository{ - Optional findByIdAndUserId(Long id, Long userId); - - Order save(Order order); - - Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java deleted file mode 100644 index 979d19fbd..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.application.order.OrderItemRequest; -import com.loopers.domain.product.Product; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@RequiredArgsConstructor -@Component -public class OrderService { - private final OrderRepository orderRepository; - - public Order save(Order order) { - return orderRepository.save(order); - } - - public Order createOrder(List OrderItems, Map productMap, Long userId) { - List orderItems = OrderItems - .stream() - .map(item -> OrderItem.create( - item.productId(), - productMap.get(item.productId()).getName(), - item.quantity(), - productMap.get(item.productId()).getPrice() - )) - .toList(); - Order order = Order.create(userId, orderItems); - - return orderRepository.save(order); - } - - public Order getOrderByIdAndUserId(Long orderId, Long userId) { - return orderRepository.findByIdAndUserId(orderId, userId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } - - public Page getOrdersByUserId(Long userId, Pageable pageable) { - int page = pageable.getPageNumber(); - int size = pageable.getPageSize(); - Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); - return orderRepository.findByUserIdAndDeletedAtIsNull(userId, PageRequest.of(page, size, sort)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java deleted file mode 100644 index ebe3d964b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; - -@Entity -@Table(name = "tb_point") -@Getter -public class Point extends BaseEntity { - @Column(name = "user_id", nullable = false, updatable = false, unique = true) - private Long userId; - private Long amount; - - protected Point() { - } - - private Point(Long userId, Long amount) { - this.userId = userId; - this.amount = amount; - } - - public static Point create(Long userId) { - return new Point(userId, 0L); - } - - public void charge(int otherAmount) { - if (otherAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - this.amount += otherAmount; - } - - public void deduct(int otherAmount) { - if (otherAmount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (this.amount < otherAmount) { - throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - this.amount -= otherAmount; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java deleted file mode 100644 index 07b90479c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.loopers.domain.point; - -import java.util.Optional; - -public interface PointRepository { - Optional findByUserId(Long userId); - - void save(Point point); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java deleted file mode 100644 index 2ea51a376..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@RequiredArgsConstructor -@Service -public class PointService { - private final PointRepository pointRepository; - - @Transactional - public void createPoint(Long userId) { - Point point = Point.create(userId); - pointRepository.save(point); - } - - @Transactional - public Long chargePoint(Long userId, int amount) { - Point point = pointRepository.findByUserId(userId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - point.charge(amount); - pointRepository.save(point); - return point.getAmount(); - } - - @Transactional(readOnly = true) - public Optional getCurrentPoint(Long userId) { - return pointRepository.findByUserId(userId).map(Point::getAmount); - } - - @Transactional - public void checkAndDeductPoint(Long userId, Integer totalAmount) { - Point point = pointRepository.findByUserId(userId).orElseThrow( - () -> new CoreException(ErrorType.NOT_FOUND, "ํฌ์ธํŠธ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.") - ); - point.deduct(totalAmount); - pointRepository.save(point); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java deleted file mode 100644 index 250516420..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.BaseEntity; -import com.loopers.domain.common.vo.Price; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; - -@Entity -@Table(name = "tb_product") -@Getter -public class Product extends BaseEntity { - protected Product() { - } - - private String name; - @Column(name = "brand_id", nullable = false, updatable = false) - private Long brandId; - @Convert(converter = Price.Converter.class) - private Price price; - - public static Product create(String name, Long brandId, Price price) { - if (StringUtils.isBlank(name)) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (brandId == null || brandId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (price == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - if (price.amount() < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - Product product = new Product(); - product.name = name; - product.brandId = brandId; - product.price = price; - return product; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java deleted file mode 100644 index beb141147..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.domain.product; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface ProductRepository { - Optional findById(Long productId); - - Page findAll(Pageable pageable); - - List findAllByIdIn(Collection ids); - - boolean existsById(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java deleted file mode 100644 index 9a4bf8842..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.Map; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class ProductService { - private final ProductRepository productRepository; - - public Product getProductById(Long productId) { - return productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } - - public Map getProductMapByIds(Collection productIds) { - return productRepository.findAllByIdIn(productIds) - .stream() - .collect(Collectors.toMap(Product::getId, product -> product)); - } - - public Page getProducts(Pageable pageable) { - int page = pageable.getPageNumber(); - int size = pageable.getPageSize(); - String sortStr = pageable.getSort().toString().split(":")[0]; - Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); - - if (StringUtils.startsWith(sortStr, "price_asc")) { - sort = Sort.by(Sort.Direction.ASC, "price"); - } - return productRepository.findAll(PageRequest.of(page, size, sort)); - } - - public Integer calculateTotalAmount(Map items) { - return productRepository.findAllByIdIn(items.keySet()) - .stream() - .mapToInt(product -> product.getPrice().amount() * items.get(product.getId())) - .sum(); - } - - public boolean existsById(Long productId) { - return productRepository.existsById(productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java deleted file mode 100644 index 834bd4628..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/supply/Supply.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.loopers.domain.supply; - -import com.loopers.domain.BaseEntity; -import com.loopers.domain.supply.vo.Stock; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import lombok.Setter; - -@Entity -@Table(name = "tb_supply") -@Getter -public class Supply extends BaseEntity { - private Long productId; - @Setter - @Convert(converter = Stock.Converter.class) - private Stock stock; - // think: ์ธ๋‹น ๊ตฌ๋งค์ œํ•œ? - - protected Supply() { - } - - public static Supply create(Long productId, Stock stock) { - if (productId == null || productId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (stock == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - Supply supply = new Supply(); - supply.productId = productId; - supply.stock = stock; - return supply; - } - - // decreaseStock, increaseStock - public void decreaseStock(int quantity) { - this.stock = this.stock.decrease(quantity); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java deleted file mode 100644 index 7e7cb09ca..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.domain.supply; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface SupplyRepository { - Optional findByProductId(Long productId); - - List findAllByProductIdIn(Collection productIds); - - Optional findByProductIdForUpdate(Long productId); - - Supply save(Supply supply); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java deleted file mode 100644 index b8de2c1df..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/supply/SupplyService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.loopers.domain.supply; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collection; -import java.util.Map; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class SupplyService { - private final SupplyRepository supplyRepository; - - public Supply getSupplyByProductId(Long productId) { - return supplyRepository.findByProductId(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - } - - public Map getSupplyMapByProductIds(Collection productIds) { - return supplyRepository.findAllByProductIdIn(productIds) - .stream() - .collect(Collectors.toMap(Supply::getProductId, supply -> supply)); - } - - @Transactional - public void checkAndDecreaseStock(Long productId, Integer quantity) { - Supply supply = supplyRepository.findByProductIdForUpdate(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "ํ•ด๋‹น ์ƒํ’ˆ์˜ ์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - supply.decreaseStock(quantity); - supplyRepository.save(supply); - } - - public Supply saveSupply(Supply supply) { - return supplyRepository.save(supply); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java deleted file mode 100644 index b76e5f1a1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/supply/vo/Stock.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.loopers.domain.supply.vo; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.AttributeConverter; - -public record Stock(int quantity) { - public Stock { - if (quantity < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - } - - public boolean isOutOfStock() { - return this.quantity <= 0; - } - - public Stock decrease(int orderQuantity) { - if (orderQuantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - if (orderQuantity > this.quantity) { - throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - return new Stock(this.quantity - orderQuantity); - } - - public static class Converter implements AttributeConverter { - - @Override - public Integer convertToDatabaseColumn(Stock attribute) { - return attribute.quantity(); - } - - @Override - public Stock convertToEntityAttribute(Integer dbData) { - return new Stock(dbData); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java deleted file mode 100644 index bd8bc6adf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Table; -import lombok.Getter; -import org.apache.commons.lang3.StringUtils; - -@Entity -@Table(name = "tb_user") -@Getter -public class User extends BaseEntity { - protected User() { - } - - @Column(nullable = false, unique = true, length = 10) - private String userId; - private String email; - private String birthday; - private String gender; - - private User(String userId, String email, String birthday, String gender) { - this.userId = userId; - this.email = email; - this.birthday = birthday; - this.gender = gender; - } - - public static User create(String userId, String email, String birthday, String gender) { - // ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (StringUtils.isBlank(userId) || !userId.matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{1,10}$")) { - throw new CoreException(ErrorType.BAD_REQUEST, "ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (StringUtils.isBlank(email) || !email.matches("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - // ์ƒ๋…„์›”์ผ์ด YYYY-MM-DD ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - if (StringUtils.isBlank(birthday) || !birthday.matches("^\\d{4}-\\d{2}-\\d{2}$")) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - // ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. - if (StringUtils.isBlank(gender)) { - throw new CoreException(ErrorType.BAD_REQUEST, "์„ฑ๋ณ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค."); - } - - return new User(userId, email, birthday, gender); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java deleted file mode 100644 index 90f701fbd..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.domain.user; - -import java.util.Optional; - -public interface UserRepository { - Optional findByUserId(String userId); - - Optional findByUserIdForUpdate(String userId); - - boolean existsUserByUserId(String userId); - - User save(User user); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java deleted file mode 100644 index 8b1129b45..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@RequiredArgsConstructor -@Service -public class UserService { - private final UserRepository userRepository; - - @Transactional - public User registerUser(String userId, String email, String birthday, String gender) { - // ์ด๋ฏธ ๋“ฑ๋ก๋œ userId ์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚จ๋‹ค. - if (userRepository.existsUserByUserId(userId)) { - throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); - } - User user = User.create(userId, email, birthday, gender); - return userRepository.save(user); - } - - @Transactional(readOnly = true) - public Optional findByUserId(String userId) { - return userRepository.findByUserId(userId); - } - - // find by user id with lock for update - @Transactional - public Optional findByUserIdForUpdate(String userId) { - return userRepository.findByUserIdForUpdate(userId); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java deleted file mode 100644 index aa99ac6ca..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.infrastructure.brand; - -import com.loopers.domain.brand.Brand; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface BrandJpaRepository extends JpaRepository { - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java deleted file mode 100644 index 69b7cbb79..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.infrastructure.brand; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class BrandRepositoryImpl implements BrandRepository { - private final BrandJpaRepository brandJpaRepository; - - @Override - public Optional findById(Long id) { - return brandJpaRepository.findById(id); - } - - @Override - public Collection findAllByIdIn(Collection ids) { - return brandJpaRepository.findAllById(ids); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java deleted file mode 100644 index 6c247ec44..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductJpaRepository.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.infrastructure.like; - -import com.loopers.domain.like.product.LikeProduct; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.time.ZonedDateTime; -import java.util.Optional; - -public interface LikeProductJpaRepository extends JpaRepository { - Optional findByUserIdAndProductId(Long userId, Long productId); - - boolean existsByUserIdAndProductId(Long userId, Long productId); - - Page getLikeProductsByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java deleted file mode 100644 index 8827a431f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeProductRepositoryImpl.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.loopers.infrastructure.like; - -import com.loopers.domain.like.product.LikeProduct; -import com.loopers.domain.like.product.LikeProductRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class LikeProductRepositoryImpl implements LikeProductRepository { - private final LikeProductJpaRepository likeProductJpaRepository; - - @Override - public boolean existsByUserIdAndProductId(Long userId, Long productId) { - return likeProductJpaRepository.existsByUserIdAndProductId(userId, productId); - } - - @Override - public Optional findByUserIdAndProductId(Long userId, Long productId) { - return likeProductJpaRepository.findByUserIdAndProductId(userId, productId); - } - - @Override - public void save(LikeProduct likeProduct) { - likeProductJpaRepository.save(likeProduct); - } - - @Override - public Page getLikeProductsByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable) { - return likeProductJpaRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, pageable); - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java deleted file mode 100644 index 42bde9788..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsJpaRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.infrastructure.metrics.product; - -import com.loopers.domain.metrics.product.ProductMetrics; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface ProductMetricsJpaRepository extends JpaRepository { - Optional findByProductId(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java deleted file mode 100644 index 3fb0ec2ce..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/metrics/product/ProductMetricsRepositoryImpl.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.infrastructure.metrics.product; - -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.metrics.product.ProductMetricsRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ProductMetricsRepositoryImpl implements ProductMetricsRepository { - private final ProductMetricsJpaRepository jpaRepository; - - @Override - public Optional findByProductId(Long productId) { - return jpaRepository.findByProductId(productId); - } - - @Override - public Collection findByProductIds(Collection productIds) { - return jpaRepository.findAllById(productIds); - } - - @Override - public Page findAll(Pageable pageable) { - return jpaRepository.findAll(pageable); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java deleted file mode 100644 index c3337045e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.Order; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface OrderJpaRepository extends JpaRepository { - - Optional findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId); - - Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable); - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java deleted file mode 100644 index 67a7462fe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class OrderRepositoryImpl implements OrderRepository { - private final OrderJpaRepository orderJpaRepository; - - @Override - public Optional findByIdAndUserId(Long id, Long userId) { - return orderJpaRepository.findByIdAndUserIdAndDeletedAtIsNull(id, userId); - } - - @Override - public Order save(Order order) { - return orderJpaRepository.save(order); - } - - @Override - public Page findByUserIdAndDeletedAtIsNull(Long userId, Pageable pageable) { - return orderJpaRepository.findByUserIdAndDeletedAtIsNull(userId, pageable); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java deleted file mode 100644 index 74320f6a2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface PointJpaRepository extends JpaRepository { - - Optional findByUserId(Long userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java deleted file mode 100644 index 052de7762..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.infrastructure.point; - -import com.loopers.domain.point.Point; -import com.loopers.domain.point.PointRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class PointRepositoryImpl implements PointRepository { - private final PointJpaRepository pointJpaRepository; - - @Override - public Optional findByUserId(Long userId) { - return pointJpaRepository.findByUserId(userId); - } - - @Override - public void save(Point point) { - pointJpaRepository.save(point); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java deleted file mode 100644 index 0375b7ca7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.infrastructure.product; - -import com.loopers.domain.product.Product; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProductJpaRepository extends JpaRepository { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java deleted file mode 100644 index b2b1115b9..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.infrastructure.product; - -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class ProductRepositoryImpl implements ProductRepository { - private final ProductJpaRepository productJpaRepository; - - @Override - public Optional findById(Long productId) { - return productJpaRepository.findById(productId); - } - - @Override - public Page findAll(Pageable pageable) { - return productJpaRepository.findAll(pageable); - } - - @Override - public List findAllByIdIn(Collection ids) { - return productJpaRepository.findAllById(ids); - } - - @Override - public boolean existsById(Long productId) { - return productJpaRepository.existsById(productId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java deleted file mode 100644 index ec66a450d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyJpaRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.loopers.infrastructure.supply; - -import com.loopers.domain.supply.Supply; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface SupplyJpaRepository extends JpaRepository { - Optional findByProductId(Long productId); - - List findAllByProductIdIn(Collection productIds); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT s FROM Supply s WHERE s.productId = :productId") - Optional findByProductIdForUpdate(Long productId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java deleted file mode 100644 index 92b13a07b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/supply/SupplyRepositoryImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.loopers.infrastructure.supply; - -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.SupplyRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class SupplyRepositoryImpl implements SupplyRepository { - private final SupplyJpaRepository supplyJpaRepository; - - @Override - public Optional findByProductId(Long productId) { - return supplyJpaRepository.findByProductId(productId); - } - - @Override - public List findAllByProductIdIn(Collection productIds) { - return supplyJpaRepository.findAllByProductIdIn(productIds); - } - - @Override - public Optional findByProductIdForUpdate(Long productId) { - return supplyJpaRepository.findByProductIdForUpdate(productId); - } - - @Override - public Supply save(Supply supply) { - return supplyJpaRepository.save(supply); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java deleted file mode 100644 index 0f298e023..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import jakarta.persistence.LockModeType; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; - -import java.util.Optional; - -public interface UserJpaRepository extends JpaRepository { - Optional findByUserId(String userId); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT u FROM User u WHERE u.userId = :userId") - Optional findByUserIdForUpdate(String userId); - - boolean existsUserByUserId(String userId); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java deleted file mode 100644 index 2018487e8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.loopers.infrastructure.user; - -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@RequiredArgsConstructor -@Component -public class UserRepositoryImpl implements UserRepository { - private final UserJpaRepository userJpaRepository; - - @Override - public Optional findByUserId(String userId) { - return userJpaRepository.findByUserId(userId); - } - - @Override - public Optional findByUserIdForUpdate(String userId) { - return userJpaRepository.findByUserIdForUpdate(userId); - } - - @Override - public boolean existsUserByUserId(String userId) { - return userJpaRepository.existsUserByUserId(userId); - } - - @Override - public User save(User user) { - return userJpaRepository.save(user); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java deleted file mode 100644 index 716f9b735..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1ApiSpec.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.loopers.interfaces.api.like.product; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.data.domain.Pageable; -import org.springframework.web.bind.annotation.RequestHeader; - -@Tag(name = "Like Product V1 API", description = "์ƒํ’ˆ ์ข‹์•„์š” API ์ž…๋‹ˆ๋‹ค.") -public interface LikeProductV1ApiSpec { - // /api/v1/like/products/{productId} - POST - @Operation( - method = "POST", - summary = "์ƒํ’ˆ ์ข‹์•„์š” ์ถ”๊ฐ€", - description = "ํšŒ์›์ด ํŠน์ • ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse likeProduct( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - Long productId - ); - - // /api/v1/like/products/{productId} - DELETE - @Operation( - method = "DELETE", - summary = "์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ", - description = "ํšŒ์›์ด ํŠน์ • ์ƒํ’ˆ์— ๋Œ€ํ•œ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse unlikeProduct( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - Long productId - ); - - // /api/v1/like/products - GET - @Operation( - method = "GET", - summary = "ํšŒ์›์ด ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", - description = "ํšŒ์›์ด ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ๋“ค์˜ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getLikedProducts( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @Schema( - name = "ํŽ˜์ด์ง€ ์ •๋ณด", - description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ, ํŽ˜์ด์ง€ ํฌ๊ธฐ, ์ •๋ ฌ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ํŽ˜์ด์ง€ ์ •๋ณด" + - "\n- ๊ธฐ๋ณธ๊ฐ’: page=0, size=20" - ) - Pageable pageable - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java deleted file mode 100644 index f49e584d1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Controller.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.loopers.interfaces.api.like.product; - -import com.loopers.application.like.product.LikeProductFacade; -import com.loopers.application.like.product.LikeProductInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/like/products") -public class LikeProductV1Controller implements LikeProductV1ApiSpec { - private final LikeProductFacade likeProductFacade; - - @RequestMapping(method = RequestMethod.POST, path = "/{productId}") - @Override - public ApiResponse likeProduct( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @PathVariable Long productId) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - likeProductFacade.likeProduct(userId, productId); - return ApiResponse.success(null); - } - - @RequestMapping(method = RequestMethod.DELETE, path = "/{productId}") - @Override - public ApiResponse unlikeProduct( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @PathVariable Long productId - ) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - likeProductFacade.unlikeProduct(userId, productId); - return ApiResponse.success(null); - } - - @RequestMapping(method = RequestMethod.GET) - @Override - public ApiResponse getLikedProducts( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @PageableDefault(size = 20) Pageable pageable - ) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - Page likedProducts = likeProductFacade.getLikedProducts(userId, pageable); - LikeProductV1Dto.ProductsResponse response = LikeProductV1Dto.ProductsResponse.from(likedProducts); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java deleted file mode 100644 index 81c084b56..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/product/LikeProductV1Dto.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.interfaces.api.like.product; - -import com.loopers.application.like.product.LikeProductInfo; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Sort; - -import java.util.List; - -public class LikeProductV1Dto { - public record ProductResponse( - Long id, - String name, - String brand, - int price, - int likes, - int stock - ) { - public static LikeProductV1Dto.ProductResponse from(LikeProductInfo info) { - return new LikeProductV1Dto.ProductResponse( - info.id(), - info.name(), - info.brand(), - info.price(), - info.likes(), - info.stock() - ); - } - } - - public record ProductsResponse( - List content, - int totalPages, - long totalElements, - int number, - int size - - ) { - public static LikeProductV1Dto.ProductsResponse from(Page page) { - return new LikeProductV1Dto.ProductsResponse( - page.map(LikeProductV1Dto.ProductResponse::from).getContent(), - page.getTotalPages(), - page.getTotalElements(), - page.getNumber(), - page.getSize() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java deleted file mode 100644 index 37259802d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.loopers.interfaces.api.order; - -import com.loopers.application.order.OrderRequest; -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.web.bind.annotation.RequestHeader; - -@Tag(name = "Order V1 API", description = "์ฃผ๋ฌธ API ์ž…๋‹ˆ๋‹ค.") -public interface OrderV1ApiSpec { - // /api/v1/orders - POST - @Operation( - method = "POST", - summary = "์ฃผ๋ฌธ ์ƒ์„ฑ", - description = "์ƒˆ๋กœ์šด ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse createOrder( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @Schema( - name = "์ฃผ๋ฌธ ์š”์ฒญ ์ •๋ณด", - description = "์ฃผ๋ฌธ ์ƒ์„ฑ์— ํ•„์š”ํ•œ ์ •๋ณด" - ) - OrderRequest request - ); - - // /api/v1/orders - GET - @Operation( - method = "GET", - summary = "์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ", - description = "ํšŒ์›์˜ ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getOrderList( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @PageableDefault(size = 20) Pageable pageable - ); - - // /api/v1/orders/{orderId} - GET - @Operation( - method = "GET", - summary = "์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ", - description = "ํŠน์ • ์ฃผ๋ฌธ์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getOrderDetail( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @Schema( - name = "์ฃผ๋ฌธ ID", - description = "์กฐํšŒํ•  ์ฃผ๋ฌธ์˜ ID" - ) - Long orderId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java deleted file mode 100644 index 0daeabd69..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.loopers.interfaces.api.order; - -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; -import com.loopers.application.order.OrderRequest; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/orders") -public class OrderV1Controller implements OrderV1ApiSpec { - private final OrderFacade orderFacade; - - @RequestMapping(method = RequestMethod.POST) - @Override - public ApiResponse createOrder( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @RequestBody OrderRequest request - ) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - OrderInfo orderInfo = orderFacade.createOrder(userId, request); - OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(orderInfo); - return ApiResponse.success(response); - } - - @RequestMapping(method = RequestMethod.GET) - @Override - public ApiResponse getOrderList( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @PageableDefault(size = 20) Pageable pageable) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - Page orderInfos = orderFacade.getOrderList(userId, pageable); - OrderV1Dto.OrderPageResponse response = OrderV1Dto.OrderPageResponse.from(orderInfos); - return ApiResponse.success(response); - } - - @RequestMapping(method = RequestMethod.GET, path = "/{orderId}") - @Override - public ApiResponse getOrderDetail( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @PathVariable Long orderId) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - OrderInfo orderInfo = orderFacade.getOrderInfo(userId, orderId); - OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(orderInfo); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java deleted file mode 100644 index 9b5312231..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.loopers.interfaces.api.order; - -import com.loopers.application.order.OrderInfo; -import com.loopers.application.order.OrderItemInfo; -import org.springframework.data.domain.Page; - -import java.util.List; - -public class OrderV1Dto { - - public record OrderResponse( - Long orderId, - List items, - Integer totalPrice - ) { - public static OrderResponse from(OrderInfo info) { - return new OrderResponse( - info.orderId(), - OrderItem.fromList(info.items()), - info.totalPrice() - ); - } - } - - public record OrderItem( - Long productId, - String productName, - Integer quantity, - Integer totalPrice - ) { - public static OrderItem from(OrderItemInfo info) { - return new OrderItem( - info.productId(), - info.productName(), - info.quantity(), - info.totalPrice() - ); - } - - public static List fromList(List infos) { - return infos.stream() - .map(OrderItem::from) - .toList(); - } - } - - public record OrderPageResponse( - List content, - int totalPages, - long totalElements, - int number, - int size - ) { - public static OrderPageResponse from(Page page) { - return new OrderPageResponse( - page.map(OrderResponse::from).getContent(), - page.getTotalPages(), - page.getTotalElements(), - page.getNumber(), - page.getSize() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java deleted file mode 100644 index fb18c23d6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.loopers.interfaces.api.point; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Point V1 API", description = "ใ…ใ…—์ธํŠธ API ์ž…๋‹ˆ๋‹ค.") -public interface PointV1ApiSpec { - - // /points - @Operation( - method = "GET", - summary = "ํฌ์ธํŠธ ์กฐํšŒ", - description = "ํšŒ์›์˜ ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - // X-USER-ID ํ—ค๋”๊ฐ’ ์‚ฌ์šฉ - ApiResponse getUserPoints( - @Schema( - name = "ํšŒ์› ID", - description = "ํฌ์ธํŠธ๋ฅผ ์กฐํšŒํ•  ํšŒ์›์˜ ID" - ) - String userId - ); - - // /points post ํฌ์ธํŠธ ์ถฉ์ „ - @Operation( - method = "POST", - summary = "ํฌ์ธํŠธ ์ถฉ์ „", - description = "ํšŒ์›์˜ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse chargeUserPoints( - @Schema( - name = "ํšŒ์› ID", - description = "ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ํ•  ํšŒ์›์˜ ID" - ) - String userId, - @Schema( - name = "์ถฉ์ „ํ•  ํฌ์ธํŠธ", - description = "์ถฉ์ „ํ•  ํฌ์ธํŠธ ๊ธˆ์•ก. ์–‘์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค." - ) - PointV1Dto.PointChargeRequest request - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java deleted file mode 100644 index c656f69d3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.interfaces.api.point; - - -import com.loopers.application.point.PointFacade; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/points") -public class PointV1Controller implements PointV1ApiSpec { - private final PointFacade pointFacade; - - @RequestMapping(method = RequestMethod.GET) - @Override - public ApiResponse getUserPoints(@RequestHeader(value = "X-USER-ID", required = false) String userId) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - Long currentPoint = pointFacade.getCurrentPoint(userId); - PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(currentPoint); - return ApiResponse.success(response); - } - - @RequestMapping(method = RequestMethod.POST, path = "/charge") - @Override - public ApiResponse chargeUserPoints( - @RequestHeader(value = "X-USER-ID", required = false) String userId, - @RequestBody PointV1Dto.PointChargeRequest request) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - Long chargedPoint = pointFacade.chargePoint(userId, request.amount()); - PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(chargedPoint); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java deleted file mode 100644 index a42ddec01..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.interfaces.api.point; - -public class PointV1Dto { - - public record PointResponse( - Long currentPoint - ) { - public static PointV1Dto.PointResponse from(Long currentPoint) { - return new PointV1Dto.PointResponse( - currentPoint - ); - } - } - - public record PointChargeRequest( - int amount - ) { - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java deleted file mode 100644 index 5217803a1..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.loopers.interfaces.api.product; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.data.domain.Pageable; - -@Tag(name = "Product V1 API", description = "์ƒํ’ˆ API ์ž…๋‹ˆ๋‹ค.") -public interface ProductV1ApiSpec { - // /api/v1/products - GET - @Operation( - method = "GET", - summary = "์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", - description = "์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getProductList( - @Schema( - name = "ํŽ˜์ด์ง€ ์ •๋ณด", - description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ, ํŽ˜์ด์ง€ ํฌ๊ธฐ, ์ •๋ ฌ ์ •๋ณด๋ฅผ ํฌํ•จํ•œ ํŽ˜์ด์ง€ ์ •๋ณด" + - "\n- sort ์˜ต์…˜: latest (์ตœ์‹ ์ˆœ), price_asc (๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ), like_desc (์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ)" + - "\n- ๊ธฐ๋ณธ๊ฐ’: page=0, size=20, sort=latest" - ) - Pageable pageable - ); - - // /api/v1/products/{productId} - GET - @Operation( - method = "GET", - summary = "์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ", - description = "์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getProductDetail( - @Schema( - name = "์ƒํ’ˆ ID", - description = "์กฐํšŒํ•  ์ƒํ’ˆ์˜ ID" - ) - Long productId - ); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java deleted file mode 100644 index 6597f8d5d..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.interfaces.api.product; - -import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/products") -public class ProductV1Controller implements ProductV1ApiSpec { - private final ProductFacade productFacade; - - @RequestMapping(method = RequestMethod.GET) - @Override - public ApiResponse getProductList(@PageableDefault(size = 20) Pageable pageable) { - Page products = productFacade.getProductList(pageable); - ProductV1Dto.ProductsPageResponse response = ProductV1Dto.ProductsPageResponse.from(products); - return ApiResponse.success(response); - } - - @RequestMapping(method = RequestMethod.GET, path = "/{productId}") - @Override - public ApiResponse getProductDetail(@PathVariable Long productId) { - ProductInfo info = productFacade.getProductDetail(productId); - ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java deleted file mode 100644 index 29f957cf5..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.interfaces.api.product; - -import com.loopers.application.product.ProductInfo; -import org.springframework.data.domain.Page; - -import java.util.List; - -public class ProductV1Dto { - public record ProductResponse( - Long id, - String name, - String brand, - int price, - int likes, - int stock - ) { - public static ProductResponse from(ProductInfo info) { - return new ProductResponse( - info.id(), - info.name(), - info.brand(), - info.price(), - info.likes(), - info.stock() - ); - } - } - - public record ProductsPageResponse( - List content, - int totalPages, - long totalElements, - int number, - int size - ) { - public static ProductsPageResponse from(Page page) { - return new ProductsPageResponse( - page.map(ProductResponse::from).getContent(), - page.getTotalPages(), - page.getTotalElements(), - page.getNumber(), - page.getSize() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java deleted file mode 100644 index bb1a413c8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.interfaces.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "User V1 API", description = "์‚ฌ์šฉ์ž API ์ž…๋‹ˆ๋‹ค.") -public interface UserV1ApiSpec { - - @Operation( - method = "POST", - summary = "ํšŒ์› ๊ฐ€์ž…", - description = "ํšŒ์›๊ฐ€์ž…์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse registerUser( - @Schema( - name = "ํšŒ์› ๊ฐ€์ž… ์š”์ฒญ", - description = "ํšŒ์› ๊ฐ€์ž…์— ํ•„์š”ํ•œ ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•ฉ๋‹ˆ๋‹ค." - ) - UserV1Dto.UserRegisterRequest request - ); - - @Operation( - method = "GET", - summary = "๋‚ด ์ •๋ณด ์กฐํšŒ", - description = "ํšŒ์› ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค." - ) - ApiResponse getUserInfo( - @Schema( - name = "ํšŒ์› ID", - description = "์กฐํšŒํ•  ํšŒ์›์˜ ID" - ) - String userId - ); - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java deleted file mode 100644 index e61ec5290..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserFacade; -import com.loopers.application.user.UserInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.StringUtils; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/users") -public class UserV1Controller implements UserV1ApiSpec { - private final UserFacade userFacade; - - @RequestMapping(method = RequestMethod.POST) - @Override - public ApiResponse registerUser(@RequestBody UserV1Dto.UserRegisterRequest request) { - UserInfo info = userFacade.registerUser( - request.id(), - request.email(), - request.birthday(), - request.gender() - ); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); - return ApiResponse.success(response); - } - - @RequestMapping(method = RequestMethod.GET, path = "/me") - @Override - public ApiResponse getUserInfo(@RequestHeader(value = "X-USER-ID", required = false) String userId) { - if (StringUtils.isBlank(userId)) { - throw new CoreException(ErrorType.BAD_REQUEST); - } - UserInfo info = userFacade.getUserInfo(userId); - UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); - return ApiResponse.success(response); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java deleted file mode 100644 index a6500f737..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.interfaces.api.user; - -import com.loopers.application.user.UserInfo; - -public class UserV1Dto { - public record UserResponse( - String id, - String email, - String birthday, - String gender) { - public static UserResponse from(UserInfo info) { - return new UserResponse( - info.id(), - info.email(), - info.birthday(), - info.gender() - ); - } - } - - public record UserRegisterRequest( - String id, - String email, - String birthday, - String gender) { - } -} diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index a8b0b72e3..484c070d0 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: config: import: - jpa.yml -# - redis.yml + - redis.yml - logging.yml - monitoring.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java deleted file mode 100644 index 280d05573..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ /dev/null @@ -1,339 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.common.vo.Price; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.point.Point; -import com.loopers.domain.product.Product; -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.vo.Stock; -import com.loopers.domain.user.User; -import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; -import com.loopers.infrastructure.point.PointJpaRepository; -import com.loopers.infrastructure.product.ProductJpaRepository; -import com.loopers.infrastructure.supply.SupplyJpaRepository; -import com.loopers.infrastructure.user.UserJpaRepository; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -@Transactional -@DisplayName("์ฃผ๋ฌธ Facade(OrderFacade) ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ") -public class OrderFacadeIntegrationTest { - - @Autowired - private OrderFacade orderFacade; - - @Autowired - private UserJpaRepository userJpaRepository; - - @Autowired - private PointJpaRepository pointJpaRepository; - - @Autowired - private BrandJpaRepository brandJpaRepository; - - @Autowired - private ProductJpaRepository productJpaRepository; - - @Autowired - private ProductMetricsJpaRepository productMetricsJpaRepository; - - @Autowired - private SupplyJpaRepository supplyJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - private String userId; - private Long userEntityId; - private Long brandId; - private Long productId1; - private Long productId2; - private Long productId3; - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @BeforeEach - void setup() { - // User ๋“ฑ๋ก - User user = User.create("user123", "test@test.com", "1993-03-13", "male"); - User savedUser = userJpaRepository.save(user); - userId = savedUser.getUserId(); - userEntityId = savedUser.getId(); - - // Point ๋“ฑ๋ก ๋ฐ ์ถฉ์ „ - Point point = Point.create(userEntityId); - point.charge(100000); - pointJpaRepository.save(point); - - // Brand ๋“ฑ๋ก - Brand brand = Brand.create("Nike"); - Brand savedBrand = brandJpaRepository.save(brand); - brandId = savedBrand.getId(); - - // Product ๋“ฑ๋ก - Product product1 = Product.create("์ƒํ’ˆ1", brandId, new Price(10000)); - Product savedProduct1 = productJpaRepository.save(product1); - productId1 = savedProduct1.getId(); - ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); - productMetricsJpaRepository.save(metrics1); - - Product product2 = Product.create("์ƒํ’ˆ2", brandId, new Price(20000)); - Product savedProduct2 = productJpaRepository.save(product2); - productId2 = savedProduct2.getId(); - ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); - productMetricsJpaRepository.save(metrics2); - - Product product3 = Product.create("์ƒํ’ˆ3", brandId, new Price(15000)); - Product savedProduct3 = productJpaRepository.save(product3); - productId3 = savedProduct3.getId(); - ProductMetrics metrics3 = ProductMetrics.create(productId3, 0); - productMetricsJpaRepository.save(metrics3); - - // Supply ๋“ฑ๋ก (์žฌ๊ณ  ์„ค์ •) - Supply supply1 = Supply.create(productId1, new Stock(100)); - supplyJpaRepository.save(supply1); - - Supply supply2 = Supply.create(productId2, new Stock(50)); - supplyJpaRepository.save(supply2); - - Supply supply3 = Supply.create(productId3, new Stock(30)); - supplyJpaRepository.save(supply3); - } - - @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ ์‹œ, ") - @Nested - class CreateOrder { - @DisplayName("์ •์ƒ์ ์ธ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createOrder_when_validRequest() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 2), - new OrderItemRequest(productId2, 1) - ) - ); - - // act - OrderInfo orderInfo = orderFacade.createOrder(userId, request); - - // assert - assertThat(orderInfo).isNotNull(); - assertThat(orderInfo.orderId()).isNotNull(); - assertThat(orderInfo.items()).hasSize(2); - assertThat(orderInfo.totalPrice()).isEqualTo(40000); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๊ฐ€ ํฌํ•จ๋œ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdDoesNotExist() { - // arrange - Long nonExistentProductId = 99999L; - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(nonExistentProductId, 1) - ) - ); - - // act & assert - // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” productMap.get()์ด null์„ ๋ฐ˜ํ™˜ํ•˜์—ฌ NullPointerException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ - // ๋˜๋Š” SupplyService.checkAndDecreaseStock์—์„œ NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ - assertThrows(Exception.class, () -> orderFacade.createOrder(userId, request)); - } - - @DisplayName("๋‹จ์ผ ์ƒํ’ˆ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_singleProductStockInsufficient() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 99999) - ) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(exception.getMessage()).contains("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); - } - - @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ค‘ ์ผ๋ถ€๋งŒ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_partialStockInsufficient() { - // arrange - // productId1: ์žฌ๊ณ  100, productId2: ์žฌ๊ณ  50 - // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ - new OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ - ) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(exception.getMessage()).contains("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); - // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ - // ๊ฐœ์„  ํ›„์—๋Š” ๋ชจ๋“  ๋ถ€์กฑํ•œ ์ƒํ’ˆ์„ ํ•œ ๋ฒˆ์— ์•Œ๋ ค์ค„ ์ˆ˜ ์žˆ์Œ - } - - @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ชจ๋‘ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_allProductsStockInsufficient() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 99999), - new OrderItemRequest(productId2, 99999) - ) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(exception.getMessage()).contains("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); - // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ - } - - @DisplayName("Supply ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†๋Š” ์ƒํ’ˆ ์ฃผ๋ฌธ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_supplyDoesNotExist() { - // arrange - // Supply๊ฐ€ ์—†๋Š” ์ƒํ’ˆ ์ƒ์„ฑ - Product productWithoutSupply = Product.create("์žฌ๊ณ ์—†๋Š”์ƒํ’ˆ", brandId, new Price(10000)); - Product savedProduct = productJpaRepository.save(productWithoutSupply); - ProductMetrics metrics = ProductMetrics.create(savedProduct.getId(), 0); - productMetricsJpaRepository.save(metrics); - // Supply๋Š” ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ - - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(savedProduct.getId(), 1) - ) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - assertThat(exception.getMessage()).contains("์žฌ๊ณ  ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); - } - - @DisplayName("ํฌ์ธํŠธ ๋ถ€์กฑ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_pointInsufficient() { - // arrange - // ํฌ์ธํŠธ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉ - OrderRequest firstOrder = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 10) - ) - ); - orderFacade.createOrder(userId, firstOrder); - - // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId2, 1) // 20000์› ํ•„์š” (๋ถ€์กฑ) - ) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(userId, request)); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - // Note: ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ๊ฐ€ ๋จผ์ € ๋ฐœ์ƒํ•  ์ˆ˜๋„ ์žˆ์œผ๋ฏ€๋กœ, ํฌ์ธํŠธ ๋ถ€์กฑ ๋ฉ”์‹œ์ง€๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ - // ๋˜๋Š” ์žฌ๊ณ  ๋ถ€์กฑ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ (99999๋Š” ์žฌ๊ณ  ๋ถ€์กฑ) - } - - @DisplayName("ํฌ์ธํŠธ๊ฐ€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค. (Edge Case)") - @Test - void should_createOrder_when_pointExactlyMatches() { - // arrange - // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ - OrderRequest firstOrder = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ - ) - ); - orderFacade.createOrder(userId, firstOrder); - // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› - - // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์ฃผ๋ฌธ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› - ) - ); - - // act - OrderInfo orderInfo = orderFacade.createOrder(userId, request); - - // assert - assertThat(orderInfo).isNotNull(); - assertThat(orderInfo.totalPrice()).isEqualTo(10000); - } - - @DisplayName("์ค‘๋ณต ์ƒํ’ˆ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ, IllegalStateException์ด ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_duplicateProducts() { - // arrange - // ๊ฐ™์€ ์ƒํ’ˆ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ํฌํ•จ - // Note: Collectors.toMap()์€ ์ค‘๋ณต ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด IllegalStateException์„ ๋ฐœ์ƒ์‹œํ‚ด - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 2), - new OrderItemRequest(productId1, 3) // ์ค‘๋ณต - ) - ); - - // act & assert - // Note: Collectors.toMap()์—์„œ ์ค‘๋ณต ํ‚ค๋กœ ์ธํ•ด IllegalStateException ๋ฐœ์ƒ - assertThrows(IllegalStateException.class, () -> orderFacade.createOrder(userId, request)); - } - - // Note: ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ ๊ฒ€์ฆ์€ E2E ํ…Œ์ŠคํŠธ์—์„œ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ๋” ์ ์ ˆํ•จ - // ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋Š” @Transactional๋กœ ์ธํ•ด ๋กค๋ฐฑ์ด ์ œ๋Œ€๋กœ ๊ฒ€์ฆ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž๋กœ ์ฃผ๋ฌธ ์‹œ๋„ ์‹œ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_userDoesNotExist() { - // arrange - String nonExistentUserId = "nonexist"; - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> orderFacade.createOrder(nonExistentUserId, request)); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - assertThat(exception.getMessage()).contains("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); - } - - // Note: Point ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž ํ…Œ์ŠคํŠธ๋Š” E2E ํ…Œ์ŠคํŠธ์—์„œ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ๋” ์ ์ ˆํ•จ - // ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ๋Š” @Transactional๋กœ ์ธํ•ด ๋กค๋ฐฑ์ด ์ œ๋Œ€๋กœ ๊ฒ€์ฆ๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Œ - // E2E ํ…Œ์ŠคํŠธ์—์„œ ์‹ค์ œ HTTP ์š”์ฒญ์„ ํ†ตํ•ด ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ์„ ๊ฒ€์ฆํ•  ์ˆ˜ ์žˆ์Œ - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java deleted file mode 100644 index 3b6dd2093..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ /dev/null @@ -1,127 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("๋ธŒ๋žœ๋“œ(Brand) Entity ํ…Œ์ŠคํŠธ") -public class BrandTest { - - @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ •์ƒ์ ์ธ ์ด๋ฆ„์œผ๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createBrand_when_validName() { - // arrange - String brandName = "Nike"; - - // act - Brand brand = Brand.create(brandName); - - // assert - assertThat(brand.getName()).isEqualTo("Nike"); - } - - @DisplayName("๋นˆ ๋ฌธ์ž์—ด๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_emptyName() { - // arrange - String brandName = ""; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Brand.create(brandName)); - assertThat(exception.getMessage()).isEqualTo("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("null๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_nullName() { - // arrange - String brandName = null; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Brand.create(brandName)); - assertThat(exception.getMessage()).isEqualTo("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("๊ณต๋ฐฑ๋งŒ ์žˆ๋Š” ๋ฌธ์ž์—ด๋กœ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_blankName() { - // arrange - String brandName = " "; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Brand.create(brandName)); - assertThat(exception.getMessage()).isEqualTo("๋ธŒ๋žœ๋“œ ์ด๋ฆ„์€ ํ•„์ˆ˜์ด๋ฉฐ 1์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("๊ธด ์ด๋ฆ„์œผ๋กœ๋„ ๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_createBrand_when_longName() { - // arrange - String brandName = "A".repeat(1000); - - // act - Brand brand = Brand.create(brandName); - - // assert - assertThat(brand.getName()).isEqualTo("A".repeat(1000)); - } - } - - @DisplayName("๋ธŒ๋žœ๋“œ ์กฐํšŒ๋ฅผ ํ•  ๋•Œ, ") - @Nested - class Retrieve { - @DisplayName("์ƒ์„ฑํ•œ ๋ธŒ๋žœ๋“œ์˜ ์ด๋ฆ„์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_retrieveName_when_brandCreated() { - // arrange - Brand brand = Brand.create("Adidas"); - - // act - String name = brand.getName(); - - // assert - assertThat(name).isEqualTo("Adidas"); - } - } - - @DisplayName("๋ธŒ๋žœ๋“œ ๋™๋“ฑ์„ฑ์„ ํ™•์ธํ•  ๋•Œ, ") - @Nested - class Equality { - @DisplayName("๊ฐ™์€ ์ด๋ฆ„์„ ๊ฐ€์ง„ ๋ธŒ๋žœ๋“œ๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Edge Case)") - @Test - void should_beDifferentInstances_when_sameName() { - // arrange - String brandName = "Puma"; - Brand brand1 = Brand.create(brandName); - Brand brand2 = Brand.create(brandName); - - // act & assert - assertThat(brand1).isNotSameAs(brand2); - assertThat(brand1).isNotEqualTo(brand2); - } - - @DisplayName("๋‹ค๋ฅธ ์ด๋ฆ„์„ ๊ฐ€์ง„ ๋ธŒ๋žœ๋“œ๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Happy Path)") - @Test - void should_beDifferentInstances_when_differentNames() { - // arrange - Brand brand1 = Brand.create("Nike"); - Brand brand2 = Brand.create("Adidas"); - - // act & assert - assertThat(brand1).isNotSameAs(brand2); - assertThat(brand1).isNotEqualTo(brand2); - assertThat(brand1.getName()).isNotEqualTo(brand2.getName()); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java deleted file mode 100644 index f4427c64b..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/common/vo/PriceTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.loopers.domain.common.vo; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("๊ฐ€๊ฒฉ(Price) Value Object ํ…Œ์ŠคํŠธ") -public class PriceTest { - - @DisplayName("๊ฐ€๊ฒฉ์„ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ •์ƒ์ ์ธ ๊ฐ€๊ฒฉ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createPrice_when_validAmount() { - // arrange - int amount = 10000; - - // act - Price price = new Price(amount); - - // assert - assertThat(price.amount()).isEqualTo(10000); - } - - @DisplayName("๊ฐ€๊ฒฉ์ด 0์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_createPrice_when_amountIsZero() { - // arrange - int amount = 0; - - // act - Price price = new Price(amount); - - // assert - assertThat(price.amount()).isEqualTo(0); - } - - @DisplayName("์Œ์ˆ˜ ๊ฐ€๊ฒฉ์œผ๋กœ ์ƒ์„ฑํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @ParameterizedTest - @ValueSource(ints = {-1, -10, -100, -1000, -10000}) - void should_throwException_when_amountIsNegative(int invalidAmount) { - // arrange: invalidAmount parameter - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - new Price(invalidAmount); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java deleted file mode 100644 index d339870ce..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductServiceIntegrationTest.java +++ /dev/null @@ -1,232 +0,0 @@ -package com.loopers.domain.like.product; - -import com.loopers.domain.product.ProductService; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -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.context.bean.override.mockito.MockitoSpyBean; - -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@SpringBootTest -@Transactional -@DisplayName("์ƒํ’ˆ ์ข‹์•„์š” ์„œ๋น„์Šค(LikeProductService) ํ…Œ์ŠคํŠธ") -public class LikeProductServiceIntegrationTest { - - @MockitoSpyBean - private LikeProductRepository spyLikeProductRepository; - - @MockitoSpyBean - private ProductService spyProductService; - - @Autowired - private LikeProductService likeProductService; - - @DisplayName("์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•  ๋•Œ, ") - @Nested - class LikeProductTest { - @DisplayName("์ฒ˜์Œ ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•˜๋ฉด ์ƒˆ๋กœ์šด ์ข‹์•„์š”๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค. (Happy Path)") - @Test - void should_createLikeProduct_when_firstLike() { - // arrange - Long userId = 1L; - Long productId = 100L; - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.empty()); - - // act - likeProductService.likeProduct(userId, productId); - - // assert - verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); - ArgumentCaptor captor = ArgumentCaptor.forClass(LikeProduct.class); - verify(spyLikeProductRepository, times(1)).save(captor.capture()); - LikeProduct savedLike = captor.getValue(); - assertThat(savedLike.getUserId()).isEqualTo(1L); - assertThat(savedLike.getProductId()).isEqualTo(100L); - } - - @DisplayName("์ด๋ฏธ ์‚ญ์ œ๋œ ์ข‹์•„์š”๋ฅผ ๋‹ค์‹œ ๋“ฑ๋กํ•˜๋ฉด ๋ณต์›๋œ๋‹ค. (Idempotency)") - @Test - void should_restoreLikeProduct_when_alreadyDeleted() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct deletedLike = LikeProduct.create(userId, productId); - deletedLike.delete(); // ์‚ญ์ œ ์ƒํƒœ๋กœ ๋งŒ๋“ค๊ธฐ - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.of(deletedLike)); - - // act - likeProductService.likeProduct(userId, productId); - - // assert - verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); - verify(spyLikeProductRepository, never()).save(any()); - // restore๊ฐ€ ํ˜ธ์ถœ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ (deletedAt์ด null์ด ๋˜์–ด์•ผ ํ•จ) - assertThat(deletedLike.getDeletedAt()).isNull(); - } - - @DisplayName("๊ฐ™์€ ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐ™์€ ์ƒํ’ˆ์— ์—ฌ๋Ÿฌ ๋ฒˆ ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•ด๋„ ํ•œ ๋ฒˆ๋งŒ ์ €์žฅ๋œ๋‹ค. (Idempotency)") - @Test - void should_notCreateDuplicate_when_likeMultipleTimes() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct existingLike = LikeProduct.create(userId, productId); - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.of(existingLike)); - - // act - likeProductService.likeProduct(userId, productId); - likeProductService.likeProduct(userId, productId); - - // assert - verify(spyLikeProductRepository, times(2)).findByUserIdAndProductId(1L, 100L); - verify(spyLikeProductRepository, never()).save(any()); - } - } - - - @DisplayName("์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•  ๋•Œ, ") - @Nested - class UnlikeProduct { - @DisplayName("์กด์žฌํ•˜๋Š” ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•˜๋ฉด ์‚ญ์ œ๋œ๋‹ค. (Happy Path)") - @Test - void should_deleteLikeProduct_when_likeExists() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct likeProduct = LikeProduct.create(userId, productId); - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.of(likeProduct)); - - // act - likeProductService.unlikeProduct(userId, productId); - - // assert - verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); - assertThat(likeProduct.getDeletedAt()).isNotNull(); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š๋Š”๋‹ค. (Edge Case)") - @Test - void should_notThrowException_when_likeNotFound() { - // arrange - Long userId = 1L; - Long productId = 100L; - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.empty()); - - // act & assert - likeProductService.unlikeProduct(userId, productId); - verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); - // ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์•„์•ผ ํ•จ - } - - @DisplayName("์ด๋ฏธ ์‚ญ์ œ๋œ ์ข‹์•„์š”๋ฅผ ๋‹ค์‹œ ์ทจ์†Œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค. (Idempotency)") - @Test - void should_beIdempotent_when_unlikeDeletedLike() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct deletedLike = LikeProduct.create(userId, productId); - deletedLike.delete(); - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.of(deletedLike)); - - // act - likeProductService.unlikeProduct(userId, productId); - - // assert - verify(spyLikeProductRepository).findByUserIdAndProductId(1L, 100L); - // delete๋Š” ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•˜๋ฏ€๋กœ deletedAt์ด ๊ทธ๋Œ€๋กœ ์œ ์ง€๋˜์–ด์•ผ ํ•จ - assertThat(deletedLike.getDeletedAt()).isNotNull(); - } - } - - @DisplayName("์ข‹์•„์š” ํ† ๊ธ€์„ ํ•  ๋•Œ, ") - @Nested - class ToggleLike { - @DisplayName("์ข‹์•„์š”๊ฐ€ ์—†์œผ๋ฉด ๋“ฑ๋กํ•˜๊ณ , ์žˆ์œผ๋ฉด ์ทจ์†Œํ•œ๋‹ค. (Toggle)") - @Test - void should_toggleLike_when_likeAndUnlike() { - // arrange - Long userId = 1L; - Long productId = 100L; - when(spyLikeProductRepository.findByUserIdAndProductId(userId, productId)) - .thenReturn(Optional.empty()) - .thenReturn(Optional.of(LikeProduct.create(userId, productId))); - - // act - ์ฒซ ๋ฒˆ์งธ ํ˜ธ์ถœ: ์ข‹์•„์š” ๋“ฑ๋ก - likeProductService.likeProduct(userId, productId); - // ๋‘ ๋ฒˆ์งธ ํ˜ธ์ถœ: ์ข‹์•„์š” ์ทจ์†Œ - likeProductService.unlikeProduct(userId, productId); - - // assert - verify(spyLikeProductRepository, times(2)).findByUserIdAndProductId(1L, 100L); - verify(spyLikeProductRepository, times(1)).save(any(LikeProduct.class)); - } - } - - @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ, ") - @Nested - class GetLikedProducts { - @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ด ์žˆ์œผ๋ฉด ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnLikedProducts_when_likesExist() { - // arrange - Long userId = 1L; - Pageable pageable = PageRequest.of(0, 20); - List likedProducts = List.of( - LikeProduct.create(userId, 100L), - LikeProduct.create(userId, 200L) - ); - Page productPage = new PageImpl<>(likedProducts, pageable, 2); - when(spyLikeProductRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, pageable)) - .thenReturn(productPage); - - // act - Page result = likeProductService.getLikedProducts(userId, pageable); - - // assert - verify(spyLikeProductRepository).getLikeProductsByUserIdAndDeletedAtIsNull(1L, Pageable.ofSize(20)); - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(2); - assertThat(result.getTotalElements()).isEqualTo(2); - } - - @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์ด ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") - @Test - void should_returnEmptyList_when_noLikes() { - // arrange - Long userId = 1L; - Pageable pageable = PageRequest.of(0, 20); - Page emptyPage = new PageImpl<>(List.of(), pageable, 0); - when(spyLikeProductRepository.getLikeProductsByUserIdAndDeletedAtIsNull(userId, pageable)) - .thenReturn(emptyPage); - - // act - Page result = likeProductService.getLikedProducts(userId, pageable); - - // assert - verify(spyLikeProductRepository).getLikeProductsByUserIdAndDeletedAtIsNull(1L, Pageable.ofSize(20)); - assertThat(result).isNotNull(); - assertThat(result.getContent()).isEmpty(); - assertThat(result.getTotalElements()).isEqualTo(0); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java deleted file mode 100644 index 4b072ff62..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/product/LikeProductTest.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.loopers.domain.like.product; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("์ƒํ’ˆ ์ข‹์•„์š”(LikeProduct) Entity ํ…Œ์ŠคํŠธ") -public class LikeProductTest { - - @DisplayName("์ข‹์•„์š”๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ •์ƒ์ ์ธ userId์™€ productId๋กœ ์ข‹์•„์š”๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createLikeProduct_when_validUserIdAndProductId() { - // arrange - Long userId = 1L; - Long productId = 100L; - - // act - LikeProduct likeProduct = LikeProduct.create(userId, productId); - - // assert - assertThat(likeProduct.getUserId()).isEqualTo(1L); - assertThat(likeProduct.getProductId()).isEqualTo(100L); - } - - @DisplayName("userId๊ฐ€ 0์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_userIdIsZero() { - // arrange - Long userId = 0L; - Long productId = 100L; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("productId๊ฐ€ 0์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdIsZero() { - // arrange - Long userId = 1L; - Long productId = 0L; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("userId์™€ productId๊ฐ€ ๋ชจ๋‘ 0์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_bothIdsAreZero() { - // arrange - Long userId = 0L; - Long productId = 0L; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("์Œ์ˆ˜ userId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_userIdIsNegative() { - // arrange - Long userId = -1L; - Long productId = 100L; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("์Œ์ˆ˜ productId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdIsNegative() { - // arrange - Long userId = 1L; - Long productId = -1L; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("null userId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_userIdIsNull() { - // arrange - Long userId = null; - Long productId = 100L; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("null productId์ธ ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdIsNull() { - // arrange - Long userId = 1L; - Long productId = null; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> LikeProduct.create(userId, productId)); - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - - @DisplayName("์ข‹์•„์š” ์กฐํšŒ๋ฅผ ํ•  ๋•Œ, ") - @Nested - class Retrieve { - @DisplayName("์ƒ์„ฑํ•œ ์ข‹์•„์š”์˜ userId๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_retrieveUserId_when_likeProductCreated() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct likeProduct = LikeProduct.create(userId, productId); - - // act - Long retrievedUserId = likeProduct.getUserId(); - - // assert - assertThat(retrievedUserId).isEqualTo(1L); - } - - @DisplayName("์ƒ์„ฑํ•œ ์ข‹์•„์š”์˜ productId๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_retrieveProductId_when_likeProductCreated() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct likeProduct = LikeProduct.create(userId, productId); - - // act - Long retrievedProductId = likeProduct.getProductId(); - - // assert - assertThat(retrievedProductId).isEqualTo(100L); - } - } - - @DisplayName("์ข‹์•„์š” ๋™๋“ฑ์„ฑ์„ ํ™•์ธํ•  ๋•Œ, ") - @Nested - class Equality { - @DisplayName("๊ฐ™์€ userId์™€ productId๋ฅผ ๊ฐ€์ง„ ์ข‹์•„์š”๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Edge Case)") - @Test - void should_beDifferentInstances_when_sameUserIdAndProductId() { - // arrange - Long userId = 1L; - Long productId = 100L; - LikeProduct likeProduct1 = LikeProduct.create(userId, productId); - LikeProduct likeProduct2 = LikeProduct.create(userId, productId); - - // act & assert - assertThat(likeProduct1).isNotSameAs(likeProduct2); - assertThat(likeProduct1).isNotEqualTo(likeProduct2); - } - - @DisplayName("๋‹ค๋ฅธ userId๋ฅผ ๊ฐ€์ง„ ์ข‹์•„์š”๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Happy Path)") - @Test - void should_beDifferentInstances_when_differentUserId() { - // arrange - LikeProduct likeProduct1 = LikeProduct.create(1L, 100L); - LikeProduct likeProduct2 = LikeProduct.create(2L, 100L); - - // act & assert - assertThat(likeProduct1).isNotSameAs(likeProduct2); - assertThat(likeProduct1).isNotEqualTo(likeProduct2); - assertThat(likeProduct1.getUserId()).isNotEqualTo(likeProduct2.getUserId()); - } - - @DisplayName("๋‹ค๋ฅธ productId๋ฅผ ๊ฐ€์ง„ ์ข‹์•„์š”๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค์ด๋‹ค. (Happy Path)") - @Test - void should_beDifferentInstances_when_differentProductId() { - // arrange - LikeProduct likeProduct1 = LikeProduct.create(1L, 100L); - LikeProduct likeProduct2 = LikeProduct.create(1L, 200L); - - // act & assert - assertThat(likeProduct1).isNotSameAs(likeProduct2); - assertThat(likeProduct1).isNotEqualTo(likeProduct2); - assertThat(likeProduct1.getProductId()).isNotEqualTo(likeProduct2.getProductId()); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java deleted file mode 100644 index 35a5056a8..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java +++ /dev/null @@ -1,276 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.common.vo.Price; -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ(OrderItem) Value Object ํ…Œ์ŠคํŠธ") -public class OrderItemTest { - - @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ •์ƒ์ ์ธ ๊ฐ’์œผ๋กœ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createOrderItem_when_validValues() { - // arrange - Long productId = 1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 2; - Price price = new Price(10000); - - // act - OrderItem orderItem = OrderItem.create(productId, productName, quantity, price); - - // assert - assertThat(orderItem.getProductId()).isEqualTo(1L); - assertThat(orderItem.getProductName()).isEqualTo("์ƒํ’ˆ๋ช…"); - assertThat(orderItem.getQuantity()).isEqualTo(2); - assertThat(orderItem.getPrice().amount()).isEqualTo(10000); - } - - @DisplayName("์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_quantityIsZero() { - // arrange - Long productId = 1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 0; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_quantityIsNegative() { - // arrange - Long productId = 1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = -1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("๊ฐ€๊ฒฉ์ด 0์ธ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_createOrderItem_when_priceIsZero() { - // arrange - Long productId = 1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 1; - Price price = new Price(0); - - // act - OrderItem orderItem = OrderItem.create(productId, productName, quantity, price); - - // assert - assertThat(orderItem.getPrice().amount()).isEqualTo(0); - } - - @DisplayName("productName์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productNameIsNull() { - // arrange - Long productId = 1L; - String productName = null; - Integer quantity = 1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("productName์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productNameIsEmpty() { - // arrange - Long productId = 1L; - String productName = ""; - Integer quantity = 1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("productName์ด ๊ณต๋ฐฑ๋งŒ ์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productNameIsBlank() { - // arrange - Long productId = 1L; - String productName = " "; - Integer quantity = 1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ด๋ฉฐ ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("productId๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdIsNull() { - // arrange - Long productId = null; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("productId๊ฐ€ 0์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdIsZero() { - // arrange - Long productId = 0L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("productId๊ฐ€ ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productIdIsNegative() { - // arrange - Long productId = -1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 1; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("quantity๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_quantityIsNull() { - // arrange - Long productId = 1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = null; - Price price = new Price(10000); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์˜ ์ž์—ฐ์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("price๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_priceIsNull() { - // arrange - Long productId = 1L; - String productName = "์ƒํ’ˆ๋ช…"; - Integer quantity = 1; - Price price = null; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - OrderItem.create(productId, productName, quantity, price); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("๊ฐ€๊ฒฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } - } - - @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์˜ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ๋•Œ, ") - @Nested - class GetTotalPrice { - @DisplayName("์ •์ƒ์ ์ธ ์ˆ˜๋Ÿ‰๊ณผ ๊ฐ€๊ฒฉ์œผ๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_calculateTotalPrice_when_validQuantityAndPrice() { - // arrange - OrderItem orderItem = OrderItem.create(1L, "์ƒํ’ˆ๋ช…", 3, new Price(10000)); - - // act - Integer totalPrice = orderItem.getTotalPrice(); - - // assert - assertThat(totalPrice).isEqualTo(30000); - } - - @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ด๋ฉด ๊ฐ€๊ฒฉ๊ณผ ๋™์ผํ•˜๋‹ค. (Edge Case)") - @Test - void should_returnPrice_when_quantityIsOne() { - // arrange - OrderItem orderItem = OrderItem.create(1L, "์ƒํ’ˆ๋ช…", 1, new Price(10000)); - - // act - Integer totalPrice = orderItem.getTotalPrice(); - - // assert - assertThat(totalPrice).isEqualTo(10000); - } - - @DisplayName("๊ฐ€๊ฒฉ์ด 0์ด๋ฉด ์ด ๊ฐ€๊ฒฉ์ด 0์ด๋‹ค. (Edge Case)") - @Test - void should_returnZero_when_priceIsZero() { - // arrange - OrderItem orderItem = OrderItem.create(1L, "์ƒํ’ˆ๋ช…", 3, new Price(0)); - - // act - Integer totalPrice = orderItem.getTotalPrice(); - - // assert - assertThat(totalPrice).isEqualTo(0); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java deleted file mode 100644 index b3c481603..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.application.order.OrderItemRequest; -import com.loopers.domain.common.vo.Price; -import com.loopers.domain.product.Product; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@SpringBootTest -@Transactional -@DisplayName("์ฃผ๋ฌธ ์„œ๋น„์Šค(OrderService) ํ…Œ์ŠคํŠธ") -public class OrderServiceIntegrationTest { - - @MockitoSpyBean - private OrderRepository spyOrderRepository; - - @Autowired - private OrderService orderService; - - @DisplayName("์ฃผ๋ฌธ์„ ์ €์žฅํ•  ๋•Œ, ") - @Nested - class SaveOrder { - @DisplayName("์ •์ƒ์ ์ธ ์ฃผ๋ฌธ์„ ์ €์žฅํ•˜๋ฉด ์ฃผ๋ฌธ์ด ์ €์žฅ๋œ๋‹ค. (Happy Path)") - @Test - void should_saveOrder_when_validOrder() { - // arrange - Long userId = 1L; - List orderItemRequests = List.of( - new OrderItemRequest(1L, 2), - new OrderItemRequest(2L, 1) - ); - Map productMap = Map.of( - 1L, Product.create("์ƒํ’ˆ1", 1L, new Price(10000)), - 2L, Product.create("์ƒํ’ˆ2", 1L, new Price(20000)) - ); - - // act - Order result = orderService.createOrder(orderItemRequests, productMap, userId); - - // assert - verify(spyOrderRepository).save(any(Order.class)); - assertThat(result).isNotNull(); - assertThat(result.getUserId()).isEqualTo(1L); - assertThat(result.getOrderItems()).hasSize(2); - } - - @DisplayName("๋‹จ์ผ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์„ ๊ฐ€์ง„ ์ฃผ๋ฌธ์„ ์ €์žฅํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_saveOrder_when_singleOrderItem() { - // arrange - Long userId = 1L; - List orderItemRequests = List.of( - new OrderItemRequest(1L, 1) - ); - Map productMap = Map.of( - 1L, Product.create("์ƒํ’ˆ1", 1L, new Price(15000)) - ); - - // act - Order result = orderService.createOrder(orderItemRequests, productMap, userId); - - // assert - verify(spyOrderRepository).save(any(Order.class)); - assertThat(result).isNotNull(); - assertThat(result.getOrderItems()).hasSize(1); - } - } - - @DisplayName("์ฃผ๋ฌธ ID์™€ ์‚ฌ์šฉ์ž ID๋กœ ์ฃผ๋ฌธ์„ ์กฐํšŒํ•  ๋•Œ, ") - @Nested - class GetOrderByIdAndUserId { - @DisplayName("์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ ID์™€ ์‚ฌ์šฉ์ž ID๋กœ ์กฐํšŒํ•˜๋ฉด ์ฃผ๋ฌธ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnOrder_when_orderExists() { - // arrange - Long orderId = 1L; - Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)) - ); - Order order = Order.create(userId, orderItems); - when(spyOrderRepository.findByIdAndUserId(orderId, userId)).thenReturn(Optional.of(order)); - - // act - Order result = orderService.getOrderByIdAndUserId(orderId, userId); - - // assert - verify(spyOrderRepository).findByIdAndUserId(1L, 1L); - assertThat(result).isNotNull(); - assertThat(result.getUserId()).isEqualTo(1L); - assertThat(result.getOrderItems()).hasSize(1); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_orderNotFound() { - // arrange - Long orderId = 999L; - Long userId = 1L; - when(spyOrderRepository.findByIdAndUserId(orderId, userId)).thenReturn(Optional.empty()); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - orderService.getOrderByIdAndUserId(orderId, userId); - }); - - // assert - verify(spyOrderRepository).findByIdAndUserId(999L, 1L); - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - - @DisplayName("๋‹ค๋ฅธ ์‚ฌ์šฉ์ž์˜ ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_orderBelongsToDifferentUser() { - // arrange - Long orderId = 1L; - Long userId = 1L; - Long differentUserId = 2L; - when(spyOrderRepository.findByIdAndUserId(orderId, differentUserId)).thenReturn(Optional.empty()); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - orderService.getOrderByIdAndUserId(orderId, differentUserId); - }); - - // assert - verify(spyOrderRepository).findByIdAndUserId(1L, 2L); - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } -} - diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java deleted file mode 100644 index b4521bb1b..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.common.vo.Price; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("์ฃผ๋ฌธ(Order) Entity ํ…Œ์ŠคํŠธ") -public class OrderTest { - - @DisplayName("์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ •์ƒ์ ์ธ userId์™€ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createOrder_when_validUserIdAndOrderItems() { - // arrange - Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), - OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)) - ); - - // act - Order order = Order.create(userId, orderItems); - - // assert - assertThat(order.getUserId()).isEqualTo(1L); - assertThat(order.getOrderItems()).hasSize(2); - assertThat(order.getOrderItems().get(0).getProductId()).isEqualTo(1L); - assertThat(order.getOrderItems().get(1).getProductId()).isEqualTo(2L); - } - - @DisplayName("๋‹จ์ผ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_createOrder_when_singleOrderItem() { - // arrange - Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) - ); - - // act - Order order = Order.create(userId, orderItems); - - // assert - assertThat(order.getUserId()).isEqualTo(1L); - assertThat(order.getOrderItems()).hasSize(1); - } - - @DisplayName("๋นˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_emptyOrderItems() { - // arrange - Long userId = 1L; - List orderItems = new ArrayList<>(); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("null ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_nullOrderItems() { - // arrange - Long userId = 1L; - List orderItems = null; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("null userId๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_nullUserId() { - // arrange - Long userId = null; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); - assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("0 ์ดํ•˜์˜ userId๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_invalidUserId() { - // arrange - Long userId = 0L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) - ); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> Order.create(userId, orderItems)); - assertThat(exception.getMessage()).isEqualTo("์‚ฌ์šฉ์ž ID๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("์—ฌ๋Ÿฌ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_createOrder_when_multipleOrderItems() { - // arrange - Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), - OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)), - OrderItem.create(3L, "์ƒํ’ˆ3", 3, new Price(15000)) - ); - - // act - Order order = Order.create(userId, orderItems); - - // assert - assertThat(order.getUserId()).isEqualTo(1L); - assertThat(order.getOrderItems()).hasSize(3); - } - - } - - @DisplayName("์ฃผ๋ฌธ ์กฐํšŒ๋ฅผ ํ•  ๋•Œ, ") - @Nested - class Retrieve { - @DisplayName("์ƒ์„ฑํ•œ ์ฃผ๋ฌธ์˜ userId๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_retrieveUserId_when_orderCreated() { - // arrange - Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 1, new Price(10000)) - ); - Order order = Order.create(userId, orderItems); - - // act - Long retrievedUserId = order.getUserId(); - - // assert - assertThat(retrievedUserId).isEqualTo(1L); - } - - @DisplayName("์ƒ์„ฑํ•œ ์ฃผ๋ฌธ์˜ ์ฃผ๋ฌธ ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ๋ฅผ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_retrieveOrderItems_when_orderCreated() { - // arrange - Long userId = 1L; - List orderItems = List.of( - OrderItem.create(1L, "์ƒํ’ˆ1", 2, new Price(10000)), - OrderItem.create(2L, "์ƒํ’ˆ2", 1, new Price(20000)) - ); - Order order = Order.create(userId, orderItems); - - // act - List retrievedOrderItems = order.getOrderItems(); - - // assert - assertThat(retrievedOrderItems).hasSize(2); - assertThat(retrievedOrderItems.get(0).getProductId()).isEqualTo(1L); - assertThat(retrievedOrderItems.get(1).getProductId()).isEqualTo(2L); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java deleted file mode 100644 index 8e702e629..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class PointModelTest { - @DisplayName("ํฌ์ธํŠธ ์ถฉ์ „์„ ํ•  ๋•Œ, ") - @Nested - class Create { - // 0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค. - @DisplayName("0 ์ดํ•˜์˜ ์ •์ˆ˜๋กœ ํฌ์ธํŠธ๋ฅผ ์ถฉ์ „ ์‹œ ์‹คํŒจํ•œ๋‹ค.") - @ParameterizedTest - @ValueSource(ints = {0, -10, -100}) - void throwsException_whenPointIsZeroOrNegative(int invalidPoint) { - // arrange - Point point = Point.create(0L); - // act - CoreException result = assertThrows(CoreException.class, () -> point.charge(invalidPoint)); - - // assert - assertThat(result.getMessage()).isEqualTo("์ถฉ์ „ ํฌ์ธํŠธ๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java deleted file mode 100644 index b22645d4f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.loopers.domain.point; - -import com.loopers.application.point.PointFacade; -import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@SpringBootTest -@Transactional -public class PointServiceIntegrationTest { - @Autowired - private PointFacade pointFacade; - @Autowired - private PointService pointService; - @Autowired - private UserService userService; - - @BeforeEach - void setUp() { - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - // ์œ ์ € ๋“ฑ๋ก - User registeredUser = userService.registerUser(validId, validEmail, validBirthday, validGender); - pointService.createPoint(registeredUser.getId()); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsUserPoints_whenUserExists() { - // arrange: setUp() ๋ฉ”์„œ๋“œ์—์„œ ์ด๋ฏธ ์œ ์ € ๋“ฑ๋ก - String existingUserId = "user123"; - Long userId = userService.findByUserId(existingUserId).get().getId(); - - // act - Optional currentPoint = pointService.getCurrentPoint(userId); - - // assert - assertThat(currentPoint.orElse(null)).isEqualTo(0L); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsNullPoints_whenUserDoesNotExist() { - // arrange: setUp() ๋ฉ”์„œ๋“œ์—์„œ ๋“ฑ๋ก๋˜์ง€ ์•Š์€ ์œ ์ € ID ์‚ฌ์šฉ - Long nonExistingUserId = -1L; - - // act - Optional currentPoint = pointService.getCurrentPoint(nonExistingUserId); - - // assert - assertThat(currentPoint).isNotPresent(); - } - - //์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค. - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID ๋กœ ์ถฉ์ „์„ ์‹œ๋„ํ•œ ๊ฒฝ์šฐ, ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsExceptionWhenChargePointWithNonExistingUserId() { - // arrange - String nonExistingUserId = "nonexist"; - int chargeAmount = 1000; - - // act & assert - assertThrows(CoreException.class, () -> pointFacade.chargePoint(nonExistingUserId, chargeAmount)); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java deleted file mode 100644 index f7ccc360f..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ /dev/null @@ -1,362 +0,0 @@ -package com.loopers.domain.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.common.vo.Price; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import com.loopers.utils.DatabaseCleanUp; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; - -import java.util.*; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@SpringBootTest -@Transactional -@DisplayName("์ƒํ’ˆ ์„œ๋น„์Šค(ProductService) ํ…Œ์ŠคํŠธ") -public class ProductServiceIntegrationTest { - - @MockitoSpyBean - private ProductRepository spyProductRepository; - - @Autowired - private ProductService productService; - - @Autowired - private com.loopers.infrastructure.brand.BrandJpaRepository brandJpaRepository; - - @Autowired - private com.loopers.infrastructure.product.ProductJpaRepository productJpaRepository; - - @Autowired - private com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository productMetricsJpaRepository; - - @Autowired - private DatabaseCleanUp databaseCleanUp; - - private Long brandId; - private Long productId1; - private Long productId2; - private Long productId3; - - @BeforeEach - void setup() { - // Brand ๋“ฑ๋ก - Brand brand = Brand.create("Nike"); - Brand savedBrand = brandJpaRepository.save(brand); - brandId = savedBrand.getId(); - - // Product ๋“ฑ๋ก - Product product1 = Product.create("์ƒํ’ˆ1", brandId, new Price(10000)); - Product savedProduct1 = productJpaRepository.save(product1); - productId1 = savedProduct1.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics1 = ProductMetrics.create(productId1, 4); - productMetricsJpaRepository.save(metrics1); - - Product product2 = Product.create("์ƒํ’ˆ2", brandId, new Price(20000)); - Product savedProduct2 = productJpaRepository.save(product2); - productId2 = savedProduct2.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); - productMetricsJpaRepository.save(metrics2); - - Product product3 = Product.create("์ƒํ’ˆ3", brandId, new Price(15000)); - Product savedProduct3 = productJpaRepository.save(product3); - productId3 = savedProduct3.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics3 = ProductMetrics.create(productId3, 3); - productMetricsJpaRepository.save(metrics3); - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ์„ ์กฐํšŒํ•  ๋•Œ, ") - @Nested - class GetProductById { - @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnProduct_when_productExists() { - // arrange - Long productId = 1L; - Product product = createProduct(productId, "์ƒํ’ˆ๋ช…", 1L, 10000); - when(spyProductRepository.findById(productId)).thenReturn(Optional.of(product)); - - // act - Product result = productService.getProductById(productId); - - // assert - verify(spyProductRepository).findById(1L); - assertThat(result).isNotNull(); - assertThat(result.getId()).isEqualTo(1L); - assertThat(result.getName()).isEqualTo("์ƒํ’ˆ๋ช…"); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_productNotFound() { - // arrange - Long productId = 999L; - when(spyProductRepository.findById(productId)).thenReturn(Optional.empty()); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - productService.getProductById(productId); - }); - - // assert - verify(spyProductRepository).findById(999L); - assertThat(exception.getMessage()).isEqualTo("์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - } - - @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ID๋กœ ์ƒํ’ˆ ๋งต์„ ์กฐํšŒํ•  ๋•Œ, ") - @Nested - class GetProductMapByIds { - @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋“ค๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ๋งต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnProductMap_when_productsExist() { - // arrange - List productIds = List.of(1L, 2L, 3L); - List products = List.of( - createProduct(1L, "์ƒํ’ˆ1", 1L, 10000), - createProduct(2L, "์ƒํ’ˆ2", 1L, 20000), - createProduct(3L, "์ƒํ’ˆ3", 2L, 15000) - ); - when(spyProductRepository.findAllByIdIn(productIds)).thenReturn(products); - - // act - Map result = productService.getProductMapByIds(productIds); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).hasSize(3); - assertThat(result.get(1L).getName()).isEqualTo("์ƒํ’ˆ1"); - assertThat(result.get(2L).getName()).isEqualTo("์ƒํ’ˆ2"); - assertThat(result.get(3L).getName()).isEqualTo("์ƒํ’ˆ3"); - } - - @DisplayName("๋นˆ ID ๋ฆฌ์ŠคํŠธ๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ ๋งต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") - @Test - void should_returnEmptyMap_when_emptyIdList() { - // arrange - List productIds = Collections.emptyList(); - when(spyProductRepository.findAllByIdIn(productIds)).thenReturn(Collections.emptyList()); - - // act - Map result = productService.getProductMapByIds(productIds); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).isEmpty(); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋“ค๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ ๋งต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") - @Test - void should_returnEmptyMap_when_productsNotFound() { - // arrange - List productIds = List.of(999L, 1000L); - when(spyProductRepository.findAllByIdIn(productIds)).thenReturn(Collections.emptyList()); - - // act - Map result = productService.getProductMapByIds(productIds); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).isEmpty(); - } - } - - @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•  ๋•Œ, ") - @Nested - class GetProducts { - @DisplayName("๊ธฐ๋ณธ ํŽ˜์ด์ง€๋„ค์ด์…˜์œผ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnProductPage_when_defaultPageable() { - // arrange - Pageable pageable = PageRequest.of(0, 20); - - // act - Page result = productService.getProducts(pageable); - - // assert - verify(spyProductRepository).findAll(any(Pageable.class)); - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - assertThat(result.getTotalElements()).isEqualTo(3); - } - - @DisplayName("์ตœ์‹ ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnProductPage_when_sortedByLatest() { - // arrange - Sort sort = Sort.by("latest"); - Pageable pageable = PageRequest.of(0, 20, sort); - - // act - Page result = productService.getProducts(pageable); - - // assert - verify(spyProductRepository).findAll(any(Pageable.class)); - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - // ์ตœ์‹ ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ createdAt ๊ธฐ์ค€ ๋‚ด๋ฆผ์ฐจ์ˆœ - assertThat(result.getContent().get(0).getCreatedAt()).isAfterOrEqualTo(result.getContent().get(1).getCreatedAt()); - assertThat(result.getContent().get(1).getCreatedAt()).isAfterOrEqualTo(result.getContent().get(2).getCreatedAt()); - } - - @DisplayName("๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnProductPage_when_sortedByPriceAsc() { - // arrange - Sort sort = Sort.by("price_asc"); - Pageable pageable = PageRequest.of(0, 20, sort); - - // act - Page result = productService.getProducts(pageable); - - // assert - verify(spyProductRepository).findAll(any(Pageable.class)); - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - // ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ - assertThat(result.getContent().get(0).getPrice().amount()).isLessThanOrEqualTo(result.getContent().get(1).getPrice().amount()); - assertThat(result.getContent().get(1).getPrice().amount()).isLessThanOrEqualTo(result.getContent().get(2).getPrice().amount()); - } - - @DisplayName("์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnProductPage_when_sortedByLikesDesc() { - // arrange - Sort sort = Sort.by("likes_desc"); - Pageable pageable = PageRequest.of(0, 20, sort); - - // act - Page result = productService.getProducts(pageable); - - // assert - verify(spyProductRepository).findAll(any(Pageable.class)); - assertThat(result).isNotNull(); - assertThat(result.getContent()).hasSize(3); - // ์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ - assertThat(result.getContent().get(0).getId()).isGreaterThanOrEqualTo(result.getContent().get(1).getId()); - assertThat(result.getContent().get(1).getId()).isGreaterThanOrEqualTo(result.getContent().get(2).getId()); - } - } - - @DisplayName("์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ๋•Œ, ") - @Nested - class CalculateTotalAmount { - @DisplayName("์ •์ƒ์ ์ธ ์ƒํ’ˆ๊ณผ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_calculateTotalAmount_when_validProductsAndQuantities() { - // arrange - Map items = Map.of( - 1L, 2, - 2L, 3 - ); - List products = List.of( - createProduct(1L, "์ƒํ’ˆ1", 1L, 10000), - createProduct(2L, "์ƒํ’ˆ2", 1L, 20000) - ); - when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); - - // act - Integer result = productService.calculateTotalAmount(items); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).isEqualTo(80000); - } - - @DisplayName("๋‹จ์ผ ์ƒํ’ˆ์œผ๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_calculateTotalAmount_when_singleProduct() { - // arrange - Map items = Map.of(1L, 5); - List products = List.of(createProduct(1L, "์ƒํ’ˆ1", 1L, 10000)); - when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); - - // act - Integer result = productService.calculateTotalAmount(items); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).isEqualTo(50000); - } - - @DisplayName("์ˆ˜๋Ÿ‰์ด 1์ธ ์ƒํ’ˆ๋“ค๋กœ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_calculateTotalAmount_when_quantityIsOne() { - // arrange - Map items = Map.of( - 1L, 1, - 2L, 1 - ); - List products = List.of( - createProduct(1L, "์ƒํ’ˆ1", 1L, 10000), - createProduct(2L, "์ƒํ’ˆ2", 1L, 20000) - ); - when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); - - // act - Integer result = productService.calculateTotalAmount(items); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).isEqualTo(30000); - } - - @DisplayName("๊ฐ€๊ฒฉ์ด 0์ธ ์ƒํ’ˆ์ด ํฌํ•จ๋˜์–ด๋„ ์ด ๊ฐ€๊ฒฉ์„ ๊ณ„์‚ฐํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_calculateTotalAmount_when_priceIsZero() { - // arrange - Map items = Map.of( - 1L, 2, - 2L, 1 - ); - List products = List.of( - createProduct(1L, "์ƒํ’ˆ1", 1L, 0), - createProduct(2L, "์ƒํ’ˆ2", 1L, 20000) - ); - when(spyProductRepository.findAllByIdIn(items.keySet())).thenReturn(products); - - // act - Integer result = productService.calculateTotalAmount(items); - - // assert - verify(spyProductRepository).findAllByIdIn(any(Collection.class)); - assertThat(result).isEqualTo(20000); - } - } - - private Product createProduct(Long id, String name, Long brandId, int priceAmount) { - Product product = Product.create(name, brandId, new Price(priceAmount)); - // ํ…Œ์ŠคํŠธ์šฉ์œผ๋กœ id ์„ค์ • (๋ฆฌํ”Œ๋ ‰์…˜ ์‚ฌ์šฉ) - try { - java.lang.reflect.Field idField = Product.class.getSuperclass().getDeclaredField("id"); - idField.setAccessible(true); - idField.set(product, id); - } catch (Exception e) { - throw new RuntimeException("Failed to set Product id", e); - } - return product; - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java deleted file mode 100644 index 6a27fdd5a..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/supply/SupplyTest.java +++ /dev/null @@ -1,130 +0,0 @@ -package com.loopers.domain.supply; - -import com.loopers.domain.supply.vo.Stock; -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("์žฌ๊ณ  ๊ณต๊ธ‰(Supply) Entity ํ…Œ์ŠคํŠธ") -public class SupplyTest { - - @DisplayName("์žฌ๊ณ  ์ฐจ๊ฐ์„ ํ•  ๋•Œ, ") - @Nested - class DecreaseStock { - @DisplayName("์ •์ƒ์ ์ธ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ๊ฐ์†Œํ•œ๋‹ค. (Happy Path)") - @Test - void should_decreaseStock_when_validQuantity() { - // arrange - Supply supply = createSupply(10); - int orderQuantity = 3; - - // act - supply.decreaseStock(orderQuantity); - - // assert - assertThat(supply.getStock().quantity()).isEqualTo(7); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ ์ˆ˜๋Ÿ‰๊ณผ ์ •ํ™•ํžˆ ๊ฐ™์œผ๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") - @Test - void should_setStockToZero_when_stockEqualsOrderQuantity() { - // arrange - Supply supply = createSupply(5); - int orderQuantity = 5; - - // act - supply.decreaseStock(orderQuantity); - - // assert - assertThat(supply.getStock().quantity()).isEqualTo(0); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ 1๊ฐœ์ผ ๋•Œ 1๊ฐœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") - @Test - void should_setStockToZero_when_stockIsOneAndDecreaseOne() { - // arrange - Supply supply = createSupply(1); - int orderQuantity = 1; - - // act - supply.decreaseStock(orderQuantity); - - // assert - assertThat(supply.getStock().quantity()).isEqualTo(0); - } - - @DisplayName("0 ์ดํ•˜์˜ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @ParameterizedTest - @ValueSource(ints = {0, -1, -10}) - void should_throwException_when_orderQuantityIsZeroOrNegative(int invalidQuantity) { - // arrange - Supply supply = createSupply(10); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - supply.decreaseStock(invalidQuantity); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_orderQuantityExceedsStock() { - // arrange - Supply supply = createSupply(5); - int orderQuantity = 10; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - supply.decreaseStock(orderQuantity); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ผ ๋•Œ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_stockIsZero() { - // arrange - Supply supply = createSupply(0); - int orderQuantity = 1; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - supply.decreaseStock(orderQuantity); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("์—ฌ๋Ÿฌ ๋ฒˆ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ๋ˆ„์  ๊ฐ์†Œํ•œ๋‹ค. (Edge Case)") - @Test - void should_accumulateDecrease_when_decreaseMultipleTimes() { - // arrange - Supply supply = createSupply(10); - - // act - supply.decreaseStock(2); - supply.decreaseStock(3); - supply.decreaseStock(1); - - // assert - assertThat(supply.getStock().quantity()).isEqualTo(4); - } - } - - private Supply createSupply(int stockQuantity) { - // ํ…Œ์ŠคํŠธ์šฉ์œผ๋กœ ๋”๋ฏธ productId ์‚ฌ์šฉ - return Supply.create(1L, new Stock(stockQuantity)); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java deleted file mode 100644 index 81bf89a91..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/supply/vo/StockTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.loopers.domain.supply.vo; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -@DisplayName("์žฌ๊ณ (Stock) Value Object ํ…Œ์ŠคํŠธ") -public class StockTest { - - @DisplayName("์žฌ๊ณ ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ, ") - @Nested - class Create { - @DisplayName("์ •์ƒ์ ์ธ ์žฌ๊ณ  ์ˆ˜๋Ÿ‰์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Happy Path)") - @Test - void should_createStock_when_validQuantity() { - // arrange - int quantity = 10; - - // act - Stock stock = new Stock(quantity); - - // assert - assertThat(stock.quantity()).isEqualTo(10); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ด์–ด๋„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค. (Edge Case)") - @Test - void should_createStock_when_quantityIsZero() { - // arrange - int quantity = 0; - - // act - Stock stock = new Stock(quantity); - - // assert - assertThat(stock.quantity()).isEqualTo(0); - } - - @DisplayName("์Œ์ˆ˜ ์žฌ๊ณ ๋กœ ์ƒ์„ฑํ•  ๊ฒฝ์šฐ, ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_quantityIsNegative() { - // arrange - int quantity = -1; - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> new Stock(quantity)); - assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - - @DisplayName("์žฌ๊ณ  ์ฐจ๊ฐ์„ ํ•  ๋•Œ, ") - @Nested - class Decrease { - @DisplayName("์ •์ƒ์ ์ธ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ ๊ฐ์†Œํ•œ๋‹ค. (Happy Path)") - @Test - void should_decreaseStock_when_validQuantity() { - // arrange - Stock stock = new Stock(10); - int orderQuantity = 3; - - // act - Stock result = stock.decrease(orderQuantity); - - // assert - assertThat(result.quantity()).isEqualTo(7); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ ์ˆ˜๋Ÿ‰๊ณผ ์ •ํ™•ํžˆ ๊ฐ™์œผ๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") - @Test - void should_setStockToZero_when_stockEqualsOrderQuantity() { - // arrange - Stock stock = new Stock(5); - int orderQuantity = 5; - - // act - Stock result = stock.decrease(orderQuantity); - - // assert - assertThat(result.quantity()).isEqualTo(0); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ 1๊ฐœ์ผ ๋•Œ 1๊ฐœ ์ฐจ๊ฐํ•˜๋ฉด ์žฌ๊ณ ๊ฐ€ 0์ด ๋œ๋‹ค. (Edge Case)") - @Test - void should_setStockToZero_when_stockIsOneAndDecreaseOne() { - // arrange - Stock stock = new Stock(1); - int orderQuantity = 1; - - // act - Stock result = stock.decrease(orderQuantity); - - // assert - assertThat(result.quantity()).isEqualTo(0); - } - - @DisplayName("0 ์ดํ•˜์˜ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @ParameterizedTest - @ValueSource(ints = {0, -1, -10}) - void should_throwException_when_orderQuantityIsZeroOrNegative(int invalidQuantity) { - // arrange - Stock stock = new Stock(10); - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - stock.decrease(invalidQuantity); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_orderQuantityExceedsStock() { - // arrange - Stock stock = new Stock(5); - int orderQuantity = 10; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - stock.decrease(orderQuantity); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ผ ๋•Œ ์ฐจ๊ฐํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. (Exception)") - @Test - void should_throwException_when_stockIsZero() { - // arrange - Stock stock = new Stock(0); - int orderQuantity = 1; - - // act - CoreException exception = assertThrows(CoreException.class, () -> { - stock.decrease(orderQuantity); - }); - - // assert - assertThat(exception.getMessage()).isEqualTo("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); - } - } - - @DisplayName("์žฌ๊ณ  ๋ถ€์กฑ ํ™•์ธ์„ ํ•  ๋•Œ, ") - @Nested - class IsOutOfStock { - @DisplayName("์žฌ๊ณ ๊ฐ€ 0์ด๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Edge Case)") - @Test - void should_returnTrue_when_stockIsZero() { - // arrange - Stock stock = new Stock(0); - - // act - boolean result = stock.isOutOfStock(); - - // assert - assertThat(result).isTrue(); - } - - - @DisplayName("์žฌ๊ณ ๊ฐ€ 1 ์ด์ƒ์ด๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. (Happy Path)") - @Test - void should_returnFalse_when_stockIsPositive() { - // arrange - Stock stock = new Stock(10); - - // act - boolean result = stock.isOutOfStock(); - - // assert - assertThat(result).isFalse(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java deleted file mode 100644 index 03b394446..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; - -public class UserModelTest { - @DisplayName("ํšŒ์› ๊ฐ€์ž…์„ ํ•  ๋•Œ, ") - @Nested - class Create { - private final String validId = "user123"; - private final String validEmail = "xx@yy.zz"; - private final String validBirthday = "1993-03-13"; - private final String validGender = "male"; - - - @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") - @Test - void throwsException_whenIdIsInvalidFormat_Null() { - // arrange - String invalidId = null; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - @DisplayName("ID ๊ฐ€ ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null์•„๋‹Œ ์—ฌ๋Ÿฌ ์ž˜๋ชป๋œ ํ˜•์‹๋“ค") - @ParameterizedTest - @ValueSource(strings = { - "", // ๋นˆ ๋ฌธ์ž์—ด - "user!@#", // ์˜๋ฌธ ๋ฐ ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ๊ฒฝ์šฐ - "user1234567" // ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ดˆ๊ณผ์ธ ๊ฒฝ์šฐ - }) - void throwsException_whenIdIsInvalidFormat(String invalidId) { - // arrange: invalidId parameter - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(invalidId, validEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("ID๋Š” ์˜๋ฌธ ๋ฐ ์ˆซ์ž 10์ž ์ด๋‚ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - // extra case - // 0์ž ์ดํ•˜์ธ ๊ฒฝ์šฐ - // ์ˆซ์ž๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ - - // ์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - - @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") - @Test - void throwsException_whenEmailIsInvalidFormat_Null() { - // arrange - String invalidEmail = null; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - - @DisplayName("์ด๋ฉ”์ผ์ด xx@yy.zz ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ด ์•„๋‹Œ ์—ฌ๋Ÿฌ ์ž˜๋ชป๋œ ํ˜•์‹๋“ค") - @ParameterizedTest - @ValueSource(strings = { - "", // ๋นˆ ๋ฌธ์ž์—ด - "userexample.com", // @๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ - "user@.com", // ๋„๋ฉ”์ธ ๋ถ€๋ถ„์ด ์—†๋Š” ๊ฒฝ์šฐ - "user@example", // ์ตœ์ƒ์œ„ ๋„๋ฉ”์ธ์ด ์—†๋Š” ๊ฒฝ์šฐ - "@." // @.๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ - }) - void throwsException_whenEmailIsInvalidFormat(String invalidEmail) { - // arrange: invalidEmail parameter - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, invalidEmail, validBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - // extra case - // ๊ณต๋ฐฑ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ - - // ์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null ์ธ ๊ฒฝ์šฐ") - @Test - void throwsException_whenBirthdayIsInvalidFormat_Null() { - // arrange - String invalidBirthday = null; - - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - - @DisplayName("์ƒ๋…„์›”์ผ์ด yyyy-MM-dd ํ˜•์‹์— ๋งž์ง€ ์•Š์œผ๋ฉด, User ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค. - null์ด ์•„๋‹Œ ์—ฌ๋Ÿฌ ์ž˜๋ชป๋œ ํ˜•์‹๋“ค") - @ParameterizedTest - @ValueSource(strings = { - "13-03-1993", // ์ž˜๋ชป๋œ ํ˜•์‹ - "1993/03/13", // ์ž˜๋ชป๋œ ํ˜•์‹ - "19930313", // ์ž˜๋ชป๋œ ํ˜•์‹ - "930313", // ์ž˜๋ชป๋œ ํ˜•์‹ - "" // ๋นˆ ๋ฌธ์ž์—ด - }) - void throwsException_whenBirthdayIsInvalidFormat(String invalidBirthday) { - // arrange: invalidBirthday parameter - // act - CoreException result = assertThrows(CoreException.class, () -> { - User.create(validId, validEmail, invalidBirthday, validGender); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ƒ๋…„์›”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java deleted file mode 100644 index 0bd755cdf..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.loopers.domain.user; - -import com.loopers.support.error.CoreException; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; - -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; - -@SpringBootTest -@Transactional -public class UserServiceIntegrationTest { - @Autowired - private UserService userService; - - @MockitoSpyBean - private UserRepository spyUserRepository; - - @DisplayName("ํšŒ์› ๊ฐ€์ž…์‹œ User ์ €์žฅ์ด ์ˆ˜ํ–‰๋œ๋‹ค.") - @Test - void saveUserWhenRegister() { - // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - - // act - // ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday, validGender); - // ์ €์žฅ๋œ ์œ ์ € ์กฐํšŒ - Optional foundUser = userService.findByUserId(validId); - - // assert - verify(spyUserRepository).save(any(User.class)); - verify(spyUserRepository).findByUserId("user123"); - assertThat(foundUser).isPresent(); - assertThat(foundUser.get().getUserId()).isEqualTo("user123"); - } - - @DisplayName("์ด๋ฏธ ๊ฐ€์ž…๋œ ID ๋กœ ํšŒ์›๊ฐ€์ž… ์‹œ๋„ ์‹œ, ์‹คํŒจํ•œ๋‹ค.") - @Test - void throwsExceptionWhenRegisterWithExistingUserId() { - // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - - // act - // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday, validGender); - // ๋™์ผ ID ๋กœ ์œ ์ € ๋“ฑ๋ก ์‹œ๋„ - CoreException result = assertThrows(CoreException.class, () -> { - userService.registerUser(validId, "zz@cc.xx", "1992-06-07", "female"); - }); - - // assert - assertThat(result.getMessage()).isEqualTo("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž ID ์ž…๋‹ˆ๋‹ค."); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•  ๊ฒฝ์šฐ, ํšŒ์› ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsUserInfo_whenUserExists() { - // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday, validGender); - - // act - Optional foundUser = userService.findByUserId(validId); - - // assert - assertThat(foundUser).isPresent(); - assertThat(foundUser.get().getUserId()).isEqualTo("user123"); - } - - @DisplayName("ํ•ด๋‹น ID ์˜ ํšŒ์›์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, null ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") - @Test - void returnsNull_whenUserDoesNotExist() { - // arrange - String validId = "user123"; - String validEmail = "xx@yy.zz"; - String validBirthday = "1993-03-13"; - String validGender = "male"; - // ๊ธฐ์กด ์œ ์ € ๋“ฑ๋ก - userService.registerUser(validId, validEmail, validBirthday, validGender); - String nonExistId = "nonexist"; - - - // act - Optional foundUser = userService.findByUserId(nonExistId); - - // assert - assertThat(foundUser).isNotPresent(); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java deleted file mode 100644 index de6c6d615..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeProductV1ApiE2ETest.java +++ /dev/null @@ -1,485 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.common.vo.Price; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.product.Product; -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.SupplyService; -import com.loopers.domain.supply.vo.Stock; -import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; -import com.loopers.infrastructure.product.ProductJpaRepository; -import com.loopers.interfaces.api.like.product.LikeProductV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.transaction.annotation.Transactional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class LikeProductV1ApiE2ETest { - - private final String ENDPOINT_USER = "/api/v1/users"; - private final String ENDPOINT_LIKE_PRODUCTS = "/api/v1/like/products"; - - private final TestRestTemplate testRestTemplate; - private final DatabaseCleanUp databaseCleanUp; - private final BrandJpaRepository brandJpaRepository; - private final ProductJpaRepository productJpaRepository; - private final ProductMetricsJpaRepository productMetricsJpaRepository; - private final SupplyService supplyService; - - @Autowired - public LikeProductV1ApiE2ETest( - TestRestTemplate testRestTemplate, - DatabaseCleanUp databaseCleanUp, - BrandJpaRepository brandJpaRepository, - ProductJpaRepository productJpaRepository, - ProductMetricsJpaRepository productMetricsJpaRepository, - SupplyService supplyService - ) { - this.testRestTemplate = testRestTemplate; - this.databaseCleanUp = databaseCleanUp; - this.brandJpaRepository = brandJpaRepository; - this.productJpaRepository = productJpaRepository; - this.productMetricsJpaRepository = productMetricsJpaRepository; - this.supplyService = supplyService; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private final String validUserId = "user123"; - private final String validEmail = "xx@yy.zz"; - private final String validBirthday = "1993-03-13"; - private final String validGender = "male"; - - private Long brandId; - private Long productId1; - private Long productId2; - - @BeforeEach - @Transactional - void setupUserAndProducts() { - // User ๋“ฑ๋ก - UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( - validUserId, - validEmail, - validBirthday, - validGender - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); - - // Brand ๋“ฑ๋ก - Brand brand = Brand.create("Nike"); - Brand savedBrand = brandJpaRepository.save(brand); - brandId = savedBrand.getId(); - - // Product ๋“ฑ๋ก - Product product1 = createProduct("์ƒํ’ˆ1", brandId, 10000); - Product savedProduct1 = productJpaRepository.save(product1); - productId1 = savedProduct1.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); - productMetricsJpaRepository.save(metrics1); - // Supply ๋“ฑ๋ก - Supply supply1 = Supply.create(productId1, new Stock(100)); - supplyService.saveSupply(supply1); - - Product product2 = createProduct("์ƒํ’ˆ2", brandId, 20000); - Product savedProduct2 = productJpaRepository.save(product2); - productId2 = savedProduct2.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); - productMetricsJpaRepository.save(metrics2); - // Supply ๋“ฑ๋ก - Supply supply2 = Supply.create(productId2, new Stock(200)); - supplyService.saveSupply(supply2); - } - - - private Product createProduct(String name, Long brandId, int priceAmount) { - return Product.create(name, brandId, new Price(priceAmount)); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", validUserId); - return headers; - } - - @DisplayName("POST /api/v1/like/products/{productId}") - @Nested - class PostLikeProduct { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ์ข‹์•„์š” ๋“ฑ๋ก์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, `200 OK` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnOk_whenLikeProductSuccess() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // assert - assertTrue(response.getStatusCode().is2xxSuccessful()); - } - - @DisplayName("๊ฐ™์€ ์ƒํ’ˆ์— ์—ฌ๋Ÿฌ ๋ฒˆ ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") - @Test - void beIdempotent_whenLikeProductMultipleTimes() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response1 = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - ResponseEntity> response2 = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // assert - assertTrue(response1.getStatusCode().is2xxSuccessful()); - assertTrue(response2.getStatusCode().is2xxSuccessful()); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ข‹์•„์š” ๋“ฑ๋ก์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ข‹์•„์š” ๋“ฑ๋ก์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "nonexist"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์ข‹์•„์š” ๋“ฑ๋ก์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenProductIdDoesNotExist() { - // arrange - Long nonExistentProductId = 99999L; - String url = ENDPOINT_LIKE_PRODUCTS + "/" + nonExistentProductId; - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); - } - } - - @DisplayName("DELETE /api/v1/like/products/{productId}") - @Nested - class DeleteLikeProduct { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, `200 OK` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnOk_whenUnlikeProductSuccess() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = createHeaders(); - // ๋จผ์ € ์ข‹์•„์š” ๋“ฑ๋ก - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); - - // act - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); - - // assert - assertTrue(response.getStatusCode().is2xxSuccessful()); - } - - @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์„ ์ทจ์†Œํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค.") - @Test - void beIdempotent_whenUnlikeProductNotLiked() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); - - // assert - assertTrue(response.getStatusCode().is2xxSuccessful()); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ข‹์•„์š” ์ทจ์†Œ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.DELETE, new HttpEntity<>(null, null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ข‹์•„์š” ์ทจ์†Œ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "/" + productId1; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "nonexist"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.DELETE, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); - } - } - - @DisplayName("GET /api/v1/like/products") - @Nested - class GetLikedProducts { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ 200 OK ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") - @Test - void returnLikedProducts_whenGetLikedProductsSuccess() { - // arrange - HttpHeaders headers = createHeaders(); - // ์ข‹์•„์š” ๋“ฑ๋ก - ParameterizedTypeReference> likeResponseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_LIKE_PRODUCTS + "/" + productId1, HttpMethod.POST, new HttpEntity<>(null, headers), likeResponseType); - testRestTemplate.exchange(ENDPOINT_LIKE_PRODUCTS + "/" + productId2, HttpMethod.POST, new HttpEntity<>(null, headers), likeResponseType); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertTrue(response.getStatusCode().is2xxSuccessful()); - } - - @DisplayName("ํŽ˜์ด์ง€๋„ค์ด์…˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ํ•ด๋‹น ํŽ˜์ด์ง€์˜ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnLikedProducts_whenWithPagination() { - // arrange - String url = ENDPOINT_LIKE_PRODUCTS + "?page=0&size=10"; - HttpHeaders headers = createHeaders(); - // ์ข‹์•„์š” ๋“ฑ๋ก - ParameterizedTypeReference> likeResponseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_LIKE_PRODUCTS + "/" + productId1, HttpMethod.POST, new HttpEntity<>(null, headers), likeResponseType); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull() - ); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "nonexist"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_LIKE_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java deleted file mode 100644 index 48e79e710..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java +++ /dev/null @@ -1,830 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.application.order.OrderItemRequest; -import com.loopers.application.order.OrderRequest; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.common.vo.Price; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.product.Product; -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.vo.Stock; -import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; -import com.loopers.infrastructure.product.ProductJpaRepository; -import com.loopers.infrastructure.supply.SupplyJpaRepository; -import com.loopers.interfaces.api.order.OrderV1Dto; -import com.loopers.interfaces.api.point.PointV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class OrderV1ApiE2ETest { - - private final String ENDPOINT_USER = "/api/v1/users"; - private final String ENDPOINT_POINT = "/api/v1/points"; - private final String ENDPOINT_ORDERS = "/api/v1/orders"; - - private final TestRestTemplate testRestTemplate; - private final DatabaseCleanUp databaseCleanUp; - private final BrandJpaRepository brandJpaRepository; - private final ProductJpaRepository productJpaRepository; - private final SupplyJpaRepository supplyJpaRepository; - private final ProductMetricsJpaRepository productMetricsJpaRepository; - - @Autowired - public OrderV1ApiE2ETest( - TestRestTemplate testRestTemplate, - DatabaseCleanUp databaseCleanUp, - BrandJpaRepository brandJpaRepository, - ProductJpaRepository productJpaRepository, - SupplyJpaRepository supplyJpaRepository, - ProductMetricsJpaRepository productMetricsJpaRepository - ) { - this.testRestTemplate = testRestTemplate; - this.databaseCleanUp = databaseCleanUp; - this.brandJpaRepository = brandJpaRepository; - this.productJpaRepository = productJpaRepository; - this.supplyJpaRepository = supplyJpaRepository; - this.productMetricsJpaRepository = productMetricsJpaRepository; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private final String validUserId = "user123"; - private final String validEmail = "xx@yy.zz"; - private final String validBirthday = "1993-03-13"; - private final String validGender = "male"; - - private Long brandId; - private Long productId1; - private Long productId2; - private Long productId3; - - @BeforeEach - void setupUserAndProducts() { - // User ๋“ฑ๋ก - UserV1Dto.UserRegisterRequest userRequest = new UserV1Dto.UserRegisterRequest( - validUserId, - validEmail, - validBirthday, - validGender - ); - ParameterizedTypeReference> userResponseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(userRequest), userResponseType); - - // ํฌ์ธํŠธ ์ถฉ์ „ - HttpHeaders headers = createHeaders(); - PointV1Dto.PointChargeRequest pointRequest = new PointV1Dto.PointChargeRequest(100000); - ParameterizedTypeReference> pointResponseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, new HttpEntity<>(pointRequest, headers), pointResponseType); - - // Brand ๋“ฑ๋ก - Brand brand = Brand.create("Nike"); - Brand savedBrand = brandJpaRepository.save(brand); - brandId = savedBrand.getId(); - - // Product ๋“ฑ๋ก - Product product1 = createProduct("์ƒํ’ˆ1", brandId, 10000); - Product savedProduct1 = productJpaRepository.save(product1); - productId1 = savedProduct1.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); - productMetricsJpaRepository.save(metrics1); - - Product product2 = createProduct("์ƒํ’ˆ2", brandId, 20000); - Product savedProduct2 = productJpaRepository.save(product2); - productId2 = savedProduct2.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); - productMetricsJpaRepository.save(metrics2); - - Product product3 = createProduct("์ƒํ’ˆ3", brandId, 15000); - Product savedProduct3 = productJpaRepository.save(product3); - productId3 = savedProduct3.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics3 = ProductMetrics.create(productId3, 0); - productMetricsJpaRepository.save(metrics3); - - // Supply ๋“ฑ๋ก (์žฌ๊ณ  ์„ค์ •) - Supply supply1 = createSupply(productId1, 100); - supplyJpaRepository.save(supply1); - - Supply supply2 = createSupply(productId2, 50); - supplyJpaRepository.save(supply2); - - Supply supply3 = createSupply(productId3, 30); - supplyJpaRepository.save(supply3); - } - - private Product createProduct(String name, Long brandId, int priceAmount) { - return Product.create(name, brandId, new Price(priceAmount)); - } - - private Supply createSupply(Long productId, int stockQuantity) { - return Supply.create(productId, new Stock(stockQuantity)); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", validUserId); - return headers; - } - - @DisplayName("POST /api/v1/orders") - @Nested - class PostOrder { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์ฃผ๋ฌธ ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnOrderInfo_whenCreateOrderSuccess() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 2), - new OrderItemRequest(productId2, 1) - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().orderId()).isNotNull(), - () -> assertThat(response.getBody().data().items()).hasSize(2), - () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(40000) - ); - } - - @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•œ ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenStockInsufficient() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 99999) - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value() == 400).isTrue(); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์ฃผ๋ฌธํ•  ๊ฒฝ์šฐ, `404 Not Found` ๋˜๋Š” `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFoundOrBadRequest_whenProductIdDoesNotExist() { - // arrange - Long nonExistentProductId = 99999L; - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(nonExistentProductId, 1) - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - // Note: OrderFacade์—์„œ getProductMapByIds๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์€ ๋งต์— ํฌํ•จ๋˜์ง€ ์•Š์Œ - // ์ดํ›„ OrderItem.create์—์„œ productMap.get()์ด null์„ ๋ฐ˜ํ™˜ํ•˜๋ฉด NullPointerException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ - // ๋˜๋Š” SupplyService.checkAndDecreaseStock์—์„œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404 || response.getStatusCode().value() == 500).isTrue(); - } - - @DisplayName("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenPointInsufficient() { - // arrange - // ํฌ์ธํŠธ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉ - HttpHeaders headers = createHeaders(); - OrderRequest firstOrder = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 10) - ) - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); - - // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId2, 99999) - ) - ); - - // act - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value() == 400).isTrue(); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "nonexist"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value() == 404).isTrue(); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenProductIdDoesNotExist() { - // arrange - Long nonExistentProductId = 99999L; - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(nonExistentProductId, 1) - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value() == 404).isTrue(); - } - - @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ์ค‘ ์ผ๋ถ€๋งŒ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenPartialStockInsufficient() { - // arrange - // productId1: ์žฌ๊ณ  100, productId2: ์žฌ๊ณ  50 - // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ - new OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ - // ๊ฐœ์„  ํ›„์—๋Š” ๋ชจ๋“  ๋ถ€์กฑํ•œ ์ƒํ’ˆ์„ ํ•œ ๋ฒˆ์— ์•Œ๋ ค์ค„ ์ˆ˜ ์žˆ์Œ - } - - @DisplayName("์—ฌ๋Ÿฌ ์ƒํ’ˆ ๋ชจ๋‘ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenAllProductsStockInsufficient() { - // arrange - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 99999), - new OrderItemRequest(productId2, 99999) - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - // Note: ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ์ฒซ ๋ฒˆ์งธ ์žฌ๊ณ  ๋ถ€์กฑ ์ƒํ’ˆ์—์„œ๋งŒ ์˜ˆ์™ธ ๋ฐœ์ƒ - } - - @DisplayName("ํฌ์ธํŠธ๊ฐ€ ์ •ํ™•ํžˆ ์ผ์น˜ํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค.") - @Test - void returnOrderInfo_whenPointExactlyMatches() { - // arrange - // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ - HttpHeaders headers = createHeaders(); - OrderRequest firstOrder = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ - ) - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); - // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› - - // ์ •ํ™•ํžˆ ์ผ์น˜ํ•˜๋Š” ์ฃผ๋ฌธ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) // ์ •ํ™•ํžˆ 10000์› - ) - ); - - // act - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(10000) - ); - } - - @DisplayName("์ค‘๋ณต ์ƒํ’ˆ์ด ํฌํ•จ๋œ ๊ฒฝ์šฐ, `500 Internal Server Error` ๋˜๋Š” `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnError_whenDuplicateProducts() { - // arrange - // ๊ฐ™์€ ์ƒํ’ˆ์„ ์—ฌ๋Ÿฌ ๋ฒˆ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์— ํฌํ•จ - // Note: Collectors.toMap()์€ ์ค‘๋ณต ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด IllegalStateException์„ ๋ฐœ์ƒ์‹œํ‚ด - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 2), - new OrderItemRequest(productId1, 3) // ์ค‘๋ณต - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - // Note: Collectors.toMap()์—์„œ ์ค‘๋ณต ํ‚ค๋กœ ์ธํ•ด IllegalStateException ๋ฐœ์ƒ - // ์ด๋Š” 500 Internal Server Error๋กœ ๋ณ€ํ™˜๋˜๊ฑฐ๋‚˜, 400 Bad Request๋กœ ์ฒ˜๋ฆฌ๋  ์ˆ˜ ์žˆ์Œ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 500).isTrue(); - } - - @DisplayName("Point ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž๋กœ ์ฃผ๋ฌธ ์‹œ๋„ ์‹œ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๊ณ  ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋œ๋‹ค.") - @Test - void returnNotFoundAndRollbackStock_whenPointDoesNotExist() { - // arrange - // Point๊ฐ€ ์—†๋Š” ์‚ฌ์šฉ์ž ์ƒ์„ฑ - String userWithoutPointId = "userWithoutPoint"; - UserV1Dto.UserRegisterRequest userRequest = new UserV1Dto.UserRegisterRequest( - userWithoutPointId, - "test2@test.com", - "1993-03-13", - "male" - ); - ParameterizedTypeReference> userResponseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(userRequest), userResponseType); - // Point๋Š” ์ƒ์„ฑํ•˜์ง€ ์•Š์Œ - - // ์ดˆ๊ธฐ ์žฌ๊ณ  ํ™•์ธ - Supply initialSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); - int initialStock = initialSupply.getStock().quantity(); - - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", userWithoutPointId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(404); - // ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋˜์–ด ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ - Supply afterRollbackSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); - int afterStock = afterRollbackSupply.getStock().quantity(); - // ์žฌ๊ณ ๊ฐ€ ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ (์ดˆ๊ธฐ ์žฌ๊ณ ์™€ ๋™์ผํ•ด์•ผ ํ•จ) - assertThat(afterStock).isEqualTo(initialStock); - } - - @DisplayName("์žฌ๊ณ  ์ฐจ๊ฐ ํ›„ ํฌ์ธํŠธ ๋ถ€์กฑ ์‹œ, ๋กค๋ฐฑ๋˜์–ด ์žฌ๊ณ ๊ฐ€ ๋ณต๊ตฌ๋œ๋‹ค.") - @Test - void should_rollbackStock_whenPointInsufficientAfterStockDecrease() { - // arrange - // ์ดˆ๊ธฐ ์žฌ๊ณ  ํ™•์ธ - Supply initialSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); - int initialStock = initialSupply.getStock().quantity(); - - // ํฌ์ธํŠธ๋ฅผ ๊ฑฐ์˜ ๋ชจ๋‘ ์‚ฌ์šฉ - HttpHeaders headers = createHeaders(); - OrderRequest firstOrder = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 9) // 90000์› ์‚ฌ์šฉ - ) - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(firstOrder, headers), responseType); - // ๋‚จ์€ ํฌ์ธํŠธ: 10000์› - - // ํฌ์ธํŠธ ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ ์‹œ๋„ (์žฌ๊ณ ๋Š” ์ถฉ๋ถ„) - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 2) // 20000์› ํ•„์š” (๋ถ€์กฑ) - ) - ); - - // act - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - // ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋˜์–ด ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ - Supply afterRollbackSupply = supplyJpaRepository.findByProductId(productId1).orElseThrow(); - int afterStock = afterRollbackSupply.getStock().quantity(); - // ์ฒซ ์ฃผ๋ฌธ์—์„œ 9๊ฐœ ์ฐจ๊ฐ๋˜์—ˆ์œผ๋ฏ€๋กœ, ์ดˆ๊ธฐ ์žฌ๊ณ  - 9 = ํ˜„์žฌ ์žฌ๊ณ ์—ฌ์•ผ ํ•จ - assertThat(afterStock).isEqualTo(initialStock - 9); - } - - @DisplayName("๋ถ€๋ถ„ ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ, ๋กค๋ฐฑ๋˜์–ด ์žฌ๊ณ ๊ฐ€ ๋ณต๊ตฌ๋œ๋‹ค.") - @Test - void should_rollbackStock_whenPartialStockInsufficient() { - // arrange - // ์ดˆ๊ธฐ ์žฌ๊ณ  ํ™•์ธ - Supply initialSupply1 = supplyJpaRepository.findByProductId(productId1).orElseThrow(); - int initialStock1 = initialSupply1.getStock().quantity(); - Supply initialSupply2 = supplyJpaRepository.findByProductId(productId2).orElseThrow(); - int initialStock2 = initialSupply2.getStock().quantity(); - - // productId1์€ ์ถฉ๋ถ„ํ•˜์ง€๋งŒ productId2๋Š” ๋ถ€์กฑํ•œ ์ฃผ๋ฌธ - OrderRequest request = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 10), // ์žฌ๊ณ  ์ถฉ๋ถ„ - new OrderItemRequest(productId2, 99999) // ์žฌ๊ณ  ๋ถ€์กฑ - ) - ); - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - // ๋ชจ๋“  ์žฌ๊ณ ๊ฐ€ ๋กค๋ฐฑ๋˜์–ด ์›๋ž˜๋Œ€๋กœ ๋ณต๊ตฌ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ - Supply afterRollbackSupply1 = supplyJpaRepository.findByProductId(productId1).orElseThrow(); - int afterStock1 = afterRollbackSupply1.getStock().quantity(); - Supply afterRollbackSupply2 = supplyJpaRepository.findByProductId(productId2).orElseThrow(); - int afterStock2 = afterRollbackSupply2.getStock().quantity(); - - assertThat(afterStock1).isEqualTo(initialStock1); - assertThat(afterStock2).isEqualTo(initialStock2); - } - } - - @DisplayName("GET /api/v1/orders") - @Nested - class GetOrderList { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ ๋ชฉ๋ก์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnOrderList_whenGetOrderListSuccess() { - // arrange - HttpHeaders headers = createHeaders(); - // ์ฃผ๋ฌธ ์ƒ์„ฑ - OrderRequest orderRequest = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - ParameterizedTypeReference> orderResponseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), orderResponseType); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ todo ์ƒํƒœ์ด์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "nonexist"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value() == 404).isTrue(); - } - } - - @DisplayName("GET /api/v1/orders/{orderId}") - @Nested - class GetOrderDetail { - private Long orderId; - - @BeforeEach - void setupOrder() { - // ์ฃผ๋ฌธ ์ƒ์„ฑ - HttpHeaders headers = createHeaders(); - OrderRequest orderRequest = new OrderRequest( - List.of( - new OrderItemRequest(productId1, 1) - ) - ); - ParameterizedTypeReference> orderResponseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> orderResponse = testRestTemplate.exchange( - ENDPOINT_ORDERS, HttpMethod.POST, new HttpEntity<>(orderRequest, headers), orderResponseType); - if (orderResponse.getStatusCode().is2xxSuccessful() && orderResponse.getBody() != null) { - orderId = orderResponse.getBody().data().orderId(); - } else { - orderId = 1L; // fallback - } - } - - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ์ฃผ๋ฌธ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnOrderDetail_whenOrderExists() { - // arrange - String url = ENDPOINT_ORDERS + "/" + orderId; - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().is2xxSuccessful() || response.getStatusCode().value() == 404).isTrue(); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenOrderDoesNotExist() { - // arrange - Long nonExistentOrderId = 99999L; - String url = ENDPOINT_ORDERS + "/" + nonExistentOrderId; - HttpHeaders headers = createHeaders(); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(404); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - String url = ENDPOINT_ORDERS + "/" + orderId; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - String url = ENDPOINT_ORDERS + "/" + orderId; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - String url = ENDPOINT_ORDERS + "/" + orderId; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - String url = ENDPOINT_ORDERS + "/" + orderId; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "nonexist"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - // Note: ํ˜„์žฌ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค๋Š” ์ž‘์„ฑํ•จ - assertThat(response.getStatusCode().value() == 400 || response.getStatusCode().value() == 404).isTrue(); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java deleted file mode 100644 index fdca89097..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java +++ /dev/null @@ -1,246 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.interfaces.api.point.PointV1Dto; -import com.loopers.interfaces.api.user.UserV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class PointV1ApiE2ETest { - private final String ENDPOINT_USER = "/api/v1/users"; - private final String ENDPOINT_POINT = "/api/v1/points"; - - private final TestRestTemplate testRestTemplate; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public PointV1ApiE2ETest( - TestRestTemplate testRestTemplate, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private final String validUserId = "user123"; - private final String validEmail = "xx@yy.zz"; - private final String validBirthday = "1993-03-13"; - private final String validGender = "male"; - - @BeforeEach - void setupUser() { - UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( - validUserId, - validEmail, - validBirthday, - validGender - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); - } - - @DisplayName("GET /api/v1/points") - @Nested - class GetPoints { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ๋ณด์œ  ํฌ์ธํŠธ๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnUserPoints_whenGetUserPointsSuccess() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", validUserId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().currentPoint()).isEqualTo(0L) - ); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, null); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ € ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - String invalidUserId = "nonexist"; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", invalidUserId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT, HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(404); - } - } - - @DisplayName("POST /api/v1/points/charge") - @Nested - class ChargePoints { - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์ถฉ์ „์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ถฉ์ „๋œ ๋ณด์œ  ์ด๋Ÿ‰์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnChargedPoints_whenChargeUserPointsSuccess() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", validUserId); - PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(request, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().currentPoint()).isEqualTo(1000L) - ); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ํฌ์ธํŠธ ์ถฉ์ „์„ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(request, null); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(request, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(request, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €๋กœ ์š”์ฒญํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenChargePointsForNonExistentUser() { - // arrange - String invalidUserId = "nonexist"; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", invalidUserId); - PointV1Dto.PointChargeRequest request = new PointV1Dto.PointChargeRequest(1000); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(request, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_POINT + "/charge", HttpMethod.POST, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(404); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java deleted file mode 100644 index 55c80c579..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java +++ /dev/null @@ -1,265 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.common.vo.Price; -import com.loopers.domain.metrics.product.ProductMetrics; -import com.loopers.domain.product.Product; -import com.loopers.domain.supply.Supply; -import com.loopers.domain.supply.SupplyService; -import com.loopers.domain.supply.vo.Stock; -import com.loopers.infrastructure.brand.BrandJpaRepository; -import com.loopers.infrastructure.metrics.product.ProductMetricsJpaRepository; -import com.loopers.infrastructure.product.ProductJpaRepository; -import com.loopers.interfaces.api.product.ProductV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class ProductV1ApiE2ETest { - - private final String ENDPOINT_PRODUCTS = "/api/v1/products"; - - private final TestRestTemplate testRestTemplate; - private final DatabaseCleanUp databaseCleanUp; - private final BrandJpaRepository brandJpaRepository; - private final ProductJpaRepository productJpaRepository; - private final ProductMetricsJpaRepository productMetricsJpaRepository; -private final SupplyService supplyService; - - @Autowired - public ProductV1ApiE2ETest( - TestRestTemplate testRestTemplate, - DatabaseCleanUp databaseCleanUp, - BrandJpaRepository brandJpaRepository, - ProductJpaRepository productJpaRepository, - ProductMetricsJpaRepository productMetricsJpaRepository, - SupplyService supplyService - ) { - this.testRestTemplate = testRestTemplate; - this.databaseCleanUp = databaseCleanUp; - this.brandJpaRepository = brandJpaRepository; - this.productJpaRepository = productJpaRepository; - this.productMetricsJpaRepository = productMetricsJpaRepository; - this.supplyService = supplyService; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - private Long brandId; - private Long productId1; - private Long productId2; - - @BeforeEach - void setupProducts() { - // Brand ๋“ฑ๋ก - Brand brand = Brand.create("Nike"); - Brand savedBrand = brandJpaRepository.save(brand); - brandId = savedBrand.getId(); - - // Product ๋“ฑ๋ก - Product product1 = createProduct("์ƒํ’ˆ1", brandId, 10000); - Product savedProduct1 = productJpaRepository.save(product1); - productId1 = savedProduct1.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics1 = ProductMetrics.create(productId1, 0); - productMetricsJpaRepository.save(metrics1); - // Supply ๋“ฑ๋ก - Supply supply1 = Supply.create(productId1, new Stock(10)); - supplyService.saveSupply(supply1); - - Product product2 = createProduct("์ƒํ’ˆ2", brandId, 20000); - Product savedProduct2 = productJpaRepository.save(product2); - productId2 = savedProduct2.getId(); - // ProductMetrics ๋“ฑ๋ก - ProductMetrics metrics2 = ProductMetrics.create(productId2, 0); - productMetricsJpaRepository.save(metrics2); - // Supply ๋“ฑ๋ก - Supply supply2 = Supply.create(productId2, new Stock(20)); - supplyService.saveSupply(supply2); - } - - private Product createProduct(String name, Long brandId, int priceAmount) { - return Product.create(name, brandId, new Price(priceAmount)); - } - - @DisplayName("GET /api/v1/products") - @Nested - class GetProductList { - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒํ’ˆ ๋ชฉ๋ก์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnProductList_whenGetProductListSuccess() { - // arrange - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().content()).isNotNull(), - () -> assertThat(response.getBody().data().size()).isGreaterThanOrEqualTo(2) - ); - } - - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒํ’ˆ ๋ชฉ๋ก์„ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnProductList_whenLoggedInUser() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", "user123"); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - ENDPOINT_PRODUCTS, HttpMethod.GET, new HttpEntity<>(null, headers), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().content()).isNotNull(), - () -> assertThat(response.getBody().data().size()).isGreaterThanOrEqualTo(2) - ); - } - - @DisplayName("ํŽ˜์ด์ง€๋„ค์ด์…˜ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ํ•ด๋‹น ํŽ˜์ด์ง€์˜ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnProductList_whenWithPagination() { - // arrange - String url = ENDPOINT_PRODUCTS + "?page=0&size=10"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull() - ); - } - - @DisplayName("๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ๊ฐ€๊ฒฉ์ด ๋‚ฎ์€ ์ˆœ์œผ๋กœ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnProductList_whenSortedByPriceAsc() { - // arrange - String url = ENDPOINT_PRODUCTS + "?sort=price_asc"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - // ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ - () -> { - var products = response.getBody().data().content(); - for (int i = 0; i < products.size() - 1; i++) { - assertThat(products.get(i).price()).isLessThanOrEqualTo(products.get(i + 1).price()); - } - } - ); - } - - @DisplayName("์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ์ข‹์•„์š”๊ฐ€ ๋งŽ์€ ์ˆœ์œผ๋กœ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnProductList_whenSortedByLikesDesc() { - // arrange - String url = ENDPOINT_PRODUCTS + "?sort=like_desc"; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - // ์ข‹์•„์š” ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ ๊ฒ€์ฆ - () -> { - var products = response.getBody().data().content(); - for (int i = 0; i < products.size() - 1; i++) { - assertThat(products.get(i).likes()).isGreaterThanOrEqualTo(products.get(i + 1).likes()); - } - } - ); - } - } - - @DisplayName("GET /api/v1/products/{productId}") - @Nested - class GetProductDetail { - @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnProductDetail_whenProductExists() { - // arrange - String url = ENDPOINT_PRODUCTS + "/" + productId1; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data()).isNotNull(), - () -> assertThat(response.getBody().data().id()).isEqualTo(productId1), - () -> assertThat(response.getBody().data().name()).isEqualTo("์ƒํ’ˆ1"), - () -> assertThat(response.getBody().data().price()).isEqualTo(10000) - ); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenProductIdDoesNotExist() { - // arrange - Long nonExistentProductId = 99999L; - String url = ENDPOINT_PRODUCTS + "/" + nonExistentProductId; - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange( - url, HttpMethod.GET, new HttpEntity<>(null), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(404); - } - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java deleted file mode 100644 index dc4df056b..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ /dev/null @@ -1,202 +0,0 @@ -package com.loopers.interfaces.api; - -import com.loopers.interfaces.api.user.UserV1Dto; -import com.loopers.utils.DatabaseCleanUp; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -public class UserV1ApiE2ETest { - - private final String ENDPOINT_USER = "/api/v1/users"; - - private final TestRestTemplate testRestTemplate; - private final DatabaseCleanUp databaseCleanUp; - - @Autowired - public UserV1ApiE2ETest( - TestRestTemplate testRestTemplate, - DatabaseCleanUp databaseCleanUp - ) { - this.testRestTemplate = testRestTemplate; - this.databaseCleanUp = databaseCleanUp; - } - - @AfterEach - void tearDown() { - databaseCleanUp.truncateAllTables(); - } - - @DisplayName("POST /api/v1/users") - @Nested - class Post { - @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnUserInfo_whenRegisterSuccess() { - // arrange - UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( - "user123", - "xx@yy.zz", - "1993-03-13", - "male" - ); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo(request.id()), - () -> assertThat(response.getBody().data().email()).isEqualTo(request.email()), - () -> assertThat(response.getBody().data().birthday()).isEqualTo(request.birthday()), - () -> assertThat(response.getBody().data().gender()).isEqualTo(request.gender()) - ); - } - - @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenGenderIsMissing() { - // arrange - UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( - "user123", - "xx@yy.zz", - "1993-03-13", - null - ); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - } - - @DisplayName("GET /api/v1/users/me") - @Nested - class Get { - private final String validUserId = "user123"; - private final String validEmail = "xx@yy.zz"; - private final String validBirthday = "1993-03-13"; - private final String validGender = "male"; - - @BeforeEach - void setupUser() { - UserV1Dto.UserRegisterRequest request = new UserV1Dto.UserRegisterRequest( - validUserId, - validEmail, - validBirthday, - validGender - ); - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - testRestTemplate.exchange(ENDPOINT_USER, HttpMethod.POST, new HttpEntity<>(request), responseType); - } - - @DisplayName("๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnUserInfo_whenGetUserInfoSuccess() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", validUserId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); - - // assert - assertAll( - () -> assertTrue(response.getStatusCode().is2xxSuccessful()), - () -> assertThat(response.getBody().data().id()).isEqualTo("user123"), - () -> assertThat(response.getBody().data().email()).isEqualTo("xx@yy.zz"), - () -> assertThat(response.getBody().data().birthday()).isEqualTo("1993-03-13"), - () -> assertThat(response.getBody().data().gender()).isEqualTo("male") - ); - } - - @DisplayName("๋น„๋กœ๊ทธ์ธ ์œ ์ €๊ฐ€ ๋‚ด ์ •๋ณด ์กฐํšŒ๋ฅผ ์‹œ๋„ํ•  ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsMissing() { - // arrange - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, null); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ผ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsEmpty() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", ""); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("X-USER-ID ํ—ค๋”๊ฐ€ ๊ณต๋ฐฑ๋งŒ ์žˆ์„ ๊ฒฝ์šฐ, `400 Bad Request` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnBadRequest_whenXUserIdHeaderIsBlank() { - // arrange - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", " "); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(400); - } - - @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, `404 Not Found` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") - @Test - void returnNotFound_whenUserIdDoesNotExist() { - // arrange - String invalidUserId = "nonexist"; - HttpHeaders headers = new HttpHeaders(); - headers.add("X-USER-ID", invalidUserId); - - // act - ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() { - }; - HttpEntity requestEntity = new HttpEntity<>(null, headers); - ResponseEntity> response = testRestTemplate.exchange(ENDPOINT_USER + "/me", HttpMethod.GET, requestEntity, responseType); - - // assert - assertThat(response.getStatusCode().value()).isEqualTo(404); - } - } -} diff --git a/docker/infra-compose.yml b/docker/infra-compose.yml index d2607d47a..18e5fcf5f 100644 --- a/docker/infra-compose.yml +++ b/docker/infra-compose.yml @@ -14,97 +14,97 @@ services: volumes: - mysql-8-data:/var/lib/mysql -# redis-master: -# image: redis:7.0 -# container_name: redis-master -# ports: -# - "6379:6379" -# volumes: -# - redis_master_data:/data -# command: -# [ -# "redis-server", # redis ์„œ๋ฒ„ ์‹คํ–‰ ๋ช…๋ น์–ด -# "--appendonly", "yes", # AOF (AppendOnlyFile) ์˜์†์„ฑ ๊ธฐ๋Šฅ ์ผœ๊ธฐ -# "--save", "", -# "--latency-monitor-threshold", "100", # ํŠน์ • command ๊ฐ€ ์ง€์ • ์‹œ๊ฐ„(ms) ์ด์ƒ ๊ฑธ๋ฆฌ๋ฉด monitor ๊ธฐ๋ก -# ] -# healthcheck: -# test: ["CMD", "redis-cli", "-p", "6379", "PING"] -# interval: 5s -# timeout: 2s -# retries: 10 -# -# redis-readonly: -# image: redis:7.0 -# container_name: redis-readonly -# depends_on: -# redis-master: -# condition: service_healthy -# ports: -# - "6380:6379" -# volumes: -# - redis_readonly_data:/data -# command: -# [ -# "redis-server", -# "--appendonly", "yes", -# "--appendfsync", "everysec", -# "--replicaof", "redis-master", "6379", # replica ๋ชจ๋“œ๋กœ ์‹คํ–‰ + ์„œ๋น„์Šค ๋ช…, ์„œ๋น„์Šค ํฌํŠธ -# "--replica-read-only", "yes", # ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์„ค์ • -# "--latency-monitor-threshold", "100", -# ] -# healthcheck: -# test: ["CMD", "redis-cli", "-p", "6379", "PING"] -# interval: 5s -# timeout: 2s -# retries: 10 -# -# kafka: -# image: bitnamilegacy/kafka:3.5.1 -# container_name: kafka -# ports: -# - "9092:9092" # ์นดํ”„์นด ๋ธŒ๋กœ์ปค PORT -# - "19092:19092" # ํ˜ธ์ŠคํŠธ ๋ฆฌ์Šค๋„ˆ ์–˜ ๋–„๋ฌธ์ธ๊ฐ€ -# environment: -# - KAFKA_CFG_NODE_ID=1 # ๋ธŒ๋กœ์ปค ๊ณ ์œ  ID -# - KAFKA_CFG_PROCESS_ROLES=broker,controller # KRaft ๋ชจ๋“œ์—ฌ์„œ, broker / controller ์—ญํ•  ๋ชจ๋‘ ๋ถ€์—ฌ -# - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:19092,CONTROLLER://:9093 -# # ๋ธŒ๋กœ์ปค ํด๋ผ์ด์–ธํŠธ (PLAINTEXT), ๋ธŒ๋กœ์ปค ํ˜ธ์ŠคํŠธ (PLAINTEXT) ๋‚ด๋ถ€ ์ปจํŠธ๋กค๋Ÿฌ (CONTROLLER) -# - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:19092 -# # ์™ธ๋ถ€ ํด๋ผ์ด์–ธํŠธ ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:9092), ๋ธŒ๋กœ์ปค ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:19092) -# - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT -# # ๊ฐ ๋ฆฌ์Šค๋„ˆ๋ณ„ ๋ณด์•ˆ ํ”„๋กœํ† ์ฝœ ์„ค์ • -# - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT -# - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER # ์ปจํŠธ๋กค๋Ÿฌ ๋‹ด๋‹น ๋ฆฌ์Šค๋„ˆ ์ง€์ • -# - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@kafka:9093 # ์ปจํŠธ๋กค๋Ÿฌ ํ›„๋ณด ๋…ธ๋“œ ์ •์˜ (๋‹จ์ผ ๋ธŒ๋กœ์ปค๋ผ ์ž๊ธฐ ์ž์‹ ๋งŒ ์žˆ์Œ) -# - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1 # consumer offset ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) -# - KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 # transaction log ํ† ํ”ฝ ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) -# - KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=1 # In-Sync-Replica ์ตœ์†Œ ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) -# volumes: -# - kafka-data:/bitnami/kafka -# healthcheck: -# test: ["CMD", "bash", "-c", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"] -# interval: 10s -# timeout: 5s -# retries: 10 -# -# kafka-ui: -# image: provectuslabs/kafka-ui:latest -# container_name: kafka-ui -# depends_on: -# kafka: -# condition: service_healthy -# ports: -# - "9099:8080" -# environment: -# KAFKA_CLUSTERS_0_NAME: local # kafka-ui ์—์„œ ๋ณด์ด๋Š” ํด๋Ÿฌ์Šคํ„ฐ๋ช… -# KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 # kafka-ui ๊ฐ€ ์—ฐ๊ฒทํ•  ๋ธŒ๋กœ์ปค ์ฃผ์†Œ + redis-master: + image: redis:7.0 + container_name: redis-master + ports: + - "6379:6379" + volumes: + - redis_master_data:/data + command: + [ + "redis-server", # redis ์„œ๋ฒ„ ์‹คํ–‰ ๋ช…๋ น์–ด + "--appendonly", "yes", # AOF (AppendOnlyFile) ์˜์†์„ฑ ๊ธฐ๋Šฅ ์ผœ๊ธฐ + "--save", "", + "--latency-monitor-threshold", "100", # ํŠน์ • command ๊ฐ€ ์ง€์ • ์‹œ๊ฐ„(ms) ์ด์ƒ ๊ฑธ๋ฆฌ๋ฉด monitor ๊ธฐ๋ก + ] + healthcheck: + test: ["CMD", "redis-cli", "-p", "6379", "PING"] + interval: 5s + timeout: 2s + retries: 10 + + redis-readonly: + image: redis:7.0 + container_name: redis-readonly + depends_on: + redis-master: + condition: service_healthy + ports: + - "6380:6379" + volumes: + - redis_readonly_data:/data + command: + [ + "redis-server", + "--appendonly", "yes", + "--appendfsync", "everysec", + "--replicaof", "redis-master", "6379", # replica ๋ชจ๋“œ๋กœ ์‹คํ–‰ + ์„œ๋น„์Šค ๋ช…, ์„œ๋น„์Šค ํฌํŠธ + "--replica-read-only", "yes", # ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์„ค์ • + "--latency-monitor-threshold", "100", + ] + healthcheck: + test: ["CMD", "redis-cli", "-p", "6379", "PING"] + interval: 5s + timeout: 2s + retries: 10 + + kafka: + image: bitnamilegacy/kafka:3.5.1 + container_name: kafka + ports: + - "9092:9092" # ์นดํ”„์นด ๋ธŒ๋กœ์ปค PORT + - "19092:19092" # ํ˜ธ์ŠคํŠธ ๋ฆฌ์Šค๋„ˆ ์–˜ ๋–„๋ฌธ์ธ๊ฐ€ + environment: + - KAFKA_CFG_NODE_ID=1 # ๋ธŒ๋กœ์ปค ๊ณ ์œ  ID + - KAFKA_CFG_PROCESS_ROLES=broker,controller # KRaft ๋ชจ๋“œ์—ฌ์„œ, broker / controller ์—ญํ•  ๋ชจ๋‘ ๋ถ€์—ฌ + - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,PLAINTEXT_HOST://:19092,CONTROLLER://:9093 + # ๋ธŒ๋กœ์ปค ํด๋ผ์ด์–ธํŠธ (PLAINTEXT), ๋ธŒ๋กœ์ปค ํ˜ธ์ŠคํŠธ (PLAINTEXT) ๋‚ด๋ถ€ ์ปจํŠธ๋กค๋Ÿฌ (CONTROLLER) + - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:19092 + # ์™ธ๋ถ€ ํด๋ผ์ด์–ธํŠธ ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:9092), ๋ธŒ๋กœ์ปค ์ ‘์† ํ˜ธ์ŠคํŠธ (localhost:19092) + - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT + # ๊ฐ ๋ฆฌ์Šค๋„ˆ๋ณ„ ๋ณด์•ˆ ํ”„๋กœํ† ์ฝœ ์„ค์ • + - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=PLAINTEXT + - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER # ์ปจํŠธ๋กค๋Ÿฌ ๋‹ด๋‹น ๋ฆฌ์Šค๋„ˆ ์ง€์ • + - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=1@kafka:9093 # ์ปจํŠธ๋กค๋Ÿฌ ํ›„๋ณด ๋…ธ๋“œ ์ •์˜ (๋‹จ์ผ ๋ธŒ๋กœ์ปค๋ผ ์ž๊ธฐ ์ž์‹ ๋งŒ ์žˆ์Œ) + - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1 # consumer offset ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) + - KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 # transaction log ํ† ํ”ฝ ๋ณต์ œ ๊ฐฏ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) + - KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=1 # In-Sync-Replica ์ตœ์†Œ ์ˆ˜ (ํ˜„์žฌ๋Š” 1 - ๋กœ์ปฌ์šฉ์ด๋ผ์„œ) + volumes: + - kafka-data:/bitnami/kafka + healthcheck: + test: ["CMD", "bash", "-c", "kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + depends_on: + kafka: + condition: service_healthy + ports: + - "9099:8080" + environment: + KAFKA_CLUSTERS_0_NAME: local # kafka-ui ์—์„œ ๋ณด์ด๋Š” ํด๋Ÿฌ์Šคํ„ฐ๋ช… + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092 # kafka-ui ๊ฐ€ ์—ฐ๊ฒทํ•  ๋ธŒ๋กœ์ปค ์ฃผ์†Œ volumes: mysql-8-data: -# redis_master_data: + redis_master_data: redis_readonly_data: -# kafka-data: + kafka-data: networks: default: diff --git a/settings.gradle.kts b/settings.gradle.kts index 83ff00abc..c99fb6360 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,10 +2,10 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", -// ":apps:commerce-streamer", + ":apps:commerce-streamer", ":modules:jpa", -// ":modules:redis", -// ":modules:kafka", + ":modules:redis", + ":modules:kafka", ":supports:jackson", ":supports:logging", ":supports:monitoring", From da195a3964c6d383b3d475cce20a4263dae9fac4 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:09:54 +0900 Subject: [PATCH 066/164] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98=EB=A9=B0,=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20Batch=20Job=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-batch/build.gradle.kts | 21 +++++ .../com/loopers/CommerceBatchApplication.java | 24 ++++++ .../loopers/batch/job/demo/DemoJobConfig.java | 48 ++++++++++++ .../batch/job/demo/step/DemoTasklet.java | 32 ++++++++ .../loopers/batch/listener/ChunkListener.java | 21 +++++ .../loopers/batch/listener/JobListener.java | 53 +++++++++++++ .../batch/listener/StepMonitorListener.java | 44 +++++++++++ .../src/main/resources/application.yml | 54 +++++++++++++ .../loopers/CommerceBatchApplicationTest.java | 10 +++ .../com/loopers/job/demo/DemoJobE2ETest.java | 76 +++++++++++++++++++ settings.gradle.kts | 1 + 11 files changed, 384 insertions(+) create mode 100644 apps/commerce-batch/build.gradle.kts create mode 100644 apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java create mode 100644 apps/commerce-batch/src/main/resources/application.yml create mode 100644 apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java create mode 100644 apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java diff --git a/apps/commerce-batch/build.gradle.kts b/apps/commerce-batch/build.gradle.kts new file mode 100644 index 000000000..b22b6477c --- /dev/null +++ b/apps/commerce-batch/build.gradle.kts @@ -0,0 +1,21 @@ +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // batch + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") + + // querydsl + annotationProcessor("com.querydsl:querydsl-apt::jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java new file mode 100644 index 000000000..e5005c373 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -0,0 +1,24 @@ +package com.loopers; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +import java.util.TimeZone; + +@ConfigurationPropertiesScan +@SpringBootApplication +public class CommerceBatchApplication { + + @PostConstruct + public void started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + } + + public static void main(String[] args) { + int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); + System.exit(exitCode); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java new file mode 100644 index 000000000..7c486483f --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/DemoJobConfig.java @@ -0,0 +1,48 @@ +package com.loopers.batch.job.demo; + +import com.loopers.batch.job.demo.step.DemoTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.support.transaction.ResourcelessTransactionManager; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class DemoJobConfig { + public static final String JOB_NAME = "demoJob"; + private static final String STEP_DEMO_SIMPLE_TASK_NAME = "demoSimpleTask"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final DemoTasklet demoTasklet; + + @Bean(JOB_NAME) + public Job demoJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(categorySyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_DEMO_SIMPLE_TASK_NAME) + public Step categorySyncStep() { + return new StepBuilder(STEP_DEMO_SIMPLE_TASK_NAME, jobRepository) + .tasklet(demoTasklet, new ResourcelessTransactionManager()) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java new file mode 100644 index 000000000..800fe5a03 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/demo/step/DemoTasklet.java @@ -0,0 +1,32 @@ +package com.loopers.batch.job.demo.step; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = DemoJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class DemoTasklet implements Tasklet { + @Value("#{jobParameters['requestDate']}") + private String requestDate; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { + if (requestDate == null) { + throw new RuntimeException("requestDate is null"); + } + System.out.println("Demo Tasklet ์‹คํ–‰ (์‹คํ–‰ ์ผ์ž : " + requestDate + ")"); + Thread.sleep(1000); + System.out.println("Demo Tasklet ์ž‘์—… ์™„๋ฃŒ"); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java new file mode 100644 index 000000000..10b09b8fc --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/ChunkListener.java @@ -0,0 +1,21 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.annotation.AfterChunk; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class ChunkListener { + + @AfterChunk + void afterChunk(ChunkContext chunkContext) { + log.info( + "์ฒญํฌ ์ข…๋ฃŒ: readCount: ${chunkContext.stepContext.stepExecution.readCount}, " + + "writeCount: ${chunkContext.stepContext.stepExecution.writeCount}" + ); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java new file mode 100644 index 000000000..cb5c8bebd --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/JobListener.java @@ -0,0 +1,53 @@ +package com.loopers.batch.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.annotation.AfterJob; +import org.springframework.batch.core.annotation.BeforeJob; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JobListener { + + @BeforeJob + void beforeJob(JobExecution jobExecution) { + log.info("Job '${jobExecution.jobInstance.jobName}' ์‹œ์ž‘"); + jobExecution.getExecutionContext().putLong("startTime", System.currentTimeMillis()); + } + + @AfterJob + void afterJob(JobExecution jobExecution) { + var startTime = jobExecution.getExecutionContext().getLong("startTime"); + var endTime = System.currentTimeMillis(); + + var startDateTime = Instant.ofEpochMilli(startTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + var endDateTime = Instant.ofEpochMilli(endTime) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + var totalTime = endTime - startTime; + var duration = Duration.ofMillis(totalTime); + var hours = duration.toHours(); + var minutes = duration.toMinutes() % 60; + var seconds = duration.getSeconds() % 60; + + var message = String.format( + """ + *Start Time:* %s + *End Time:* %s + *Total Time:* %d์‹œ๊ฐ„ %d๋ถ„ %d์ดˆ + """, startDateTime, endDateTime, hours, minutes, seconds + ).trim(); + + log.info(message); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java new file mode 100644 index 000000000..4f22f40b0 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/listener/StepMonitorListener.java @@ -0,0 +1,44 @@ +package com.loopers.batch.listener; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.StepExecutionListener; +import org.springframework.stereotype.Component; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Component +public class StepMonitorListener implements StepExecutionListener { + + @Override + public void beforeStep(@Nonnull StepExecution stepExecution) { + log.info("Step '{}' ์‹œ์ž‘", stepExecution.getStepName()); + } + + @Override + public ExitStatus afterStep(@Nonnull StepExecution stepExecution) { + if (!stepExecution.getFailureExceptions().isEmpty()) { + var jobName = stepExecution.getJobExecution().getJobInstance().getJobName(); + var exceptions = stepExecution.getFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + log.info( + """ + [์—๋Ÿฌ ๋ฐœ์ƒ] + jobName: {} + exceptions: + {} + """.trim(), jobName, exceptions + ); + // error ๋ฐœ์ƒ ์‹œ slack ๋“ฑ ๋‹ค๋ฅธ ์ฑ„๋„๋กœ ๋ชจ๋‹ˆํ„ฐ ์ „์†ก + return ExitStatus.FAILED; + } + return ExitStatus.COMPLETED; + } +} diff --git a/apps/commerce-batch/src/main/resources/application.yml b/apps/commerce-batch/src/main/resources/application.yml new file mode 100644 index 000000000..9aa0d760a --- /dev/null +++ b/apps/commerce-batch/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + main: + web-application-type: none + application: + name: commerce-batch + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + batch: + job: + name: ${job.name:NONE} + jdbc: + initialize-schema: never + +management: + health: + defaults: + enabled: false + +--- +spring: + config: + activate: + on-profile: local, test + batch: + jdbc: + initialize-schema: always + +--- +spring: + config: + activate: + on-profile: dev + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java new file mode 100644 index 000000000..c5e3bc7a3 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -0,0 +1,10 @@ +package com.loopers; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class CommerceBatchApplicationTest { + @Test + void contextLoads() {} +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java new file mode 100644 index 000000000..dafe59a18 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/demo/DemoJobE2ETest.java @@ -0,0 +1,76 @@ +package com.loopers.job.demo; + +import com.loopers.batch.job.demo.DemoJobConfig; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@SpringBatchTest +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) +class DemoJobE2ETest { + + // IDE ์ •์  ๋ถ„์„ ์ƒ [SpringBatchTest] ์˜ ์ฃผ์ž…๋ณด๋‹ค [SpringBootTest] ์˜ ์ฃผ์ž…์ด ์šฐ์„ ๋˜์–ด, ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ๋Š” ์—†์œผ๋ฏ€๋กœ ์˜ค๋ฅ˜์ฒ˜๋Ÿผ ๋ณด์ผ ์ˆ˜ ์žˆ์Œ. + // [SpringBatchTest] ์ž์ฒด๊ฐ€ Scope ๊ธฐ๋ฐ˜์œผ๋กœ ์ฃผ์ž…ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ •์ƒ ๋™์ž‘ํ•จ. + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(DemoJobConfig.JOB_NAME) + private Job job; + + @BeforeEach + void beforeEach() { + + } + + @DisplayName("jobParameter ์ค‘ requestDate ์ธ์ž๊ฐ€ ์ฃผ์–ด์ง€์ง€ ์•Š์•˜์„ ๋•Œ, demoJob ๋ฐฐ์น˜๋Š” ์‹คํŒจํ•œ๋‹ค.") + @Test + void shouldNotSaveCategories_whenApiError() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.FAILED.getExitCode()) + ); + } + + @DisplayName("demoJob ๋ฐฐ์น˜๊ฐ€ ์ •์ƒ์ ์œผ๋กœ ์‹คํ–‰๋œ๋‹ค.") + @Test + void success() throws Exception { + // arrange + jobLauncherTestUtils.setJob(job); + + // act + var jobParameters = new JobParametersBuilder() + .addLocalDate("requestDate", LocalDate.now()) + .toJobParameters(); + var jobExecution = jobLauncherTestUtils.launchJob(jobParameters); + + // assert + assertAll( + () -> assertThat(jobExecution).isNotNull(), + () -> assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo(ExitStatus.COMPLETED.getExitCode()) + ); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c99fb6360..a2c303835 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:commerce-batch", ":modules:jpa", ":modules:redis", ":modules:kafka", From 6c87e71091e34a42019f9aafa136a87e468f6824 Mon Sep 17 00:00:00 2001 From: hubtwork Date: Wed, 31 Dec 2025 16:16:04 +0900 Subject: [PATCH 067/164] =?UTF-8?q?commerce-batch=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=B4=20README=20=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 04950f29d..f86e4dd8a 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ docker-compose -f ./docker/monitoring-compose.yml up Root โ”œโ”€โ”€ apps ( spring-applications ) โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ commerce-api +โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ commerce-batch โ”‚ โ””โ”€โ”€ ๐Ÿ“ฆ commerce-streamer โ”œโ”€โ”€ modules ( reusable-configurations ) โ”‚ โ”œโ”€โ”€ ๐Ÿ“ฆ jpa From ff207366416e6917ac9c2991c1559011b9e14e12 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 12 Nov 2025 14:04:03 +0900 Subject: [PATCH 068/164] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A8=EC=9B=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 --- .../java/com/loopers/domain/brand/Brand.java | 25 ++++++++ .../com/loopers/domain/brand/BrandTest.java | 57 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..c0ee86121 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,25 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brand") +public class Brand extends BaseEntity { + + private String name; + private String description; + + public Brand(String name, String description) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + if (description == null || description.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..32ce5807b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,57 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class BrandTest { + + @DisplayName("Brand ๊ฐ์ฒด ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + @Nested + class Create { + + @DisplayName("๋ชจ๋“  ๊ฐ’์ด ์œ ํšจํ•˜๋ฉด Brand ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑ์— ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void create_brand_with_valid_data() { + // assert + assertDoesNotThrow(() -> { + new Brand("name", "description"); + }); + } + + @DisplayName("Brand ๊ฐ์ฒด ์ƒ์„ฑ ์‹คํŒจ ํ…Œ์ŠคํŠธ") + @Nested + class BrandValidation { + + @Test + @DisplayName("๋ธŒ๋žœ๋“œ ์ด๋ฆ„(name)์ด ์—†๊ฑฐ๋‚˜(null) ๊ณต๋ฐฑ์ด๋ฉด, Brand ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void throwsBadRequest_whenBrandNameIsNullOrBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Brand("", "description"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("๋ธŒ๋žœ๋“œ ์„ค๋ช…(description)์ด ์—†๊ฑฐ๋‚˜(null) ๊ณต๋ฐฑ์ด๋ฉด, Brand ๊ฐ์ฒด ์ƒ์„ฑ์€ ์‹คํŒจํ•œ๋‹ค.") + void throwsBadRequest_whenBrandDescriptionIsNullOrBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Brand("name", ""); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + } +} From bec68bd13c4679d598f1eeabd5ee3d9395f56771 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 12 Nov 2025 15:48:50 +0900 Subject: [PATCH 069/164] =?UTF-8?q?feat:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/Brand.java | 14 ++++ .../loopers/domain/brand/BrandRepository.java | 9 +++ .../loopers/domain/brand/BrandService.java | 18 +++++ .../brand/BrandJpaRepository.java | 8 ++ .../brand/BrandRepositoryImpl.java | 24 ++++++ .../brand/BrandServiceIntegrationTest.java | 77 +++++++++++++++++++ 6 files changed, 150 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index c0ee86121..cb0944eeb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -13,6 +13,9 @@ public class Brand extends BaseEntity { private String name; private String description; + protected Brand() { + } + public Brand(String name, String description) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); @@ -21,5 +24,16 @@ public Brand(String name, String description) { if (description == null || description.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } + + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..0da783254 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.brand; + +import java.util.Optional; + +public interface BrandRepository { + Brand save(Brand brand); + + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..858196038 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,18 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + public Brand getBrand(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..bcbd9d705 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..3f11f2a55 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..d8c9d4d5c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,77 @@ +package com.loopers.domain.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@SpringBootTest +class BrandServiceIntegrationTest { + + @Autowired + BrandService brandService; + + @MockitoSpyBean + BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์กฐํšŒ ํ•  ๋•Œ,") + @Nested + class Get { + + @DisplayName("ํ•ด๋‹น ID ์˜ ๋ธŒ๋žœ๋“œ๊ฐ€ ์กด์žฌํ•  ๊ฒฝ์šฐ, ๋ธŒ๋žœ๋“œ ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค") + @Test + void return_brandInfo_whenValidIdIsProvided() { + // arrange + Brand brand = new Brand("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ", "ํ…Œ์ŠคํŠธ ์„ค๋ช…"); + Brand saveBrand = brandRepository.save(brand); + Long findId = saveBrand.getId(); + + // act + + + Brand result = brandService.getBrand(findId); + + // assert + assertAll( + () -> assertNotNull(result), + () -> assertThat(result.getName()).isEqualTo("ํ…Œ์ŠคํŠธ ๋ธŒ๋žœ๋“œ"), + () -> assertThat(result.getDescription()).isEqualTo("ํ…Œ์ŠคํŠธ ์„ค๋ช…") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ ID๋กœ ์กฐํšŒ ์‹œ 404 Not Found ์—๋Ÿฌ์™€ โ€œ๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.โ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void throwsException_whenBrandNotFound() { + // arrange + Long findId = 1L; + + //act + CoreException exception = assertThrows(CoreException.class, () -> { + brandService.getBrand(findId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} From 4cfbae44715721bac8262cb65a4baef88553bb71 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 12 Nov 2025 19:28:15 +0900 Subject: [PATCH 070/164] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 48 ++++++++++ .../loopers/domain/product/ProductTest.java | 92 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..256446ec7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,48 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "user") +public class Product extends BaseEntity { + + private Long brandId; + private String name; + private String description; + private long price; + private int stock; + + public Product(Long brandId, String name, String description, long price, int stock) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์ด๋ฆ„์„ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + if (description == null || description.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์„ค๋ช…์„ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + + if (price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + this.brandId = brandId; + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + } + + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..c1719255c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ProductTest { + + @DisplayName("Product ๊ฐ์ฒด ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + @Nested + class Create { + + @DisplayName("๋ชจ๋“  ๊ฐ’์ด ์œ ํšจํ•˜๋ฉด Product ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑ์— ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void create_product_with_valid_data() { + // assert + assertDoesNotThrow(() -> { + new Product(1L, "name", "description", 1000, 100); + }); + } + + @DisplayName("Product ๊ฐ์ฒด ์ƒ์„ฑ ์‹คํŒจ ํ…Œ์ŠคํŠธ") + @Nested + class ProductValidation { + + @Test + @DisplayName("๋ธŒ๋žœ๋“œid๊ฐ€ ์—†์œผ๋ฉด Product ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void shouldThrowException_whenBrandIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Product(null, "name", "description", 1000, 100); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + @Test + @DisplayName("์ œํ’ˆ ์ด๋ฆ„(description)์ด ์—†๊ฑฐ๋‚˜(null) ๊ณต๋ฐฑ์ด๋ฉด, Product ๊ฐ์ฒด ์ƒ์„ฑ์€ ์‹คํŒจํ•œ๋‹ค.") + void shouldThrowException_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Product(1L, "", "description", 1000, 100); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("์ œํ’ˆ ์„ค๋ช…(description)์ด ์—†๊ฑฐ๋‚˜(null) ๊ณต๋ฐฑ์ด๋ฉด, Product ๊ฐ์ฒด ์ƒ์„ฑ์€ ์‹คํŒจํ•œ๋‹ค.") + void shouldThrowException_whenDescriptionIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Product(1L, "name", "", 1000, 100); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("์ƒํ’ˆ ๊ฐ€๊ฒฉ์ด 0 ๋ฏธ๋งŒ์ด๋ฉด Product ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void shouldThrowException_whenPriceIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Product(1L, "", "description", -1, 100); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("์ƒํ’ˆ ์žฌ๊ณ ๊ฐ€ 0 ๋ฏธ๋งŒ์ด๋ฉด Product ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void shouldThrowException_whenStockIsNegative() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Product(1L, "name", "", 1000, -1); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + } +} From 32c8789826c9eb9df168605317c4d3ebb1534483 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 13 Nov 2025 10:53:09 +0900 Subject: [PATCH 071/164] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 23 ++++- .../domain/product/ProductRepository.java | 11 +++ .../domain/product/ProductService.java | 18 ++++ .../product/ProductJpaRepository.java | 8 ++ .../product/ProductRepositoryImpl.java | 25 ++++++ .../ProductServiceIntegrationTest.java | 85 +++++++++++++++++++ .../loopers/domain/product/ProductTest.java | 2 +- 7 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 256446ec7..a42814ebc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -7,7 +7,7 @@ import jakarta.persistence.Table; @Entity -@Table(name = "user") +@Table(name = "product") public class Product extends BaseEntity { private Long brandId; @@ -16,6 +16,9 @@ public class Product extends BaseEntity { private long price; private int stock; + protected Product() { + } + public Product(Long brandId, String name, String description, long price, int stock) { if (brandId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); @@ -44,5 +47,23 @@ public Product(Long brandId, String name, String description, long price, int st this.stock = stock; } + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + public long getPrice() { + return price; + } + + public int getStock() { + return stock; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..a89a6b87b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,11 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + + Product save(Product product); + + Page findAll(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..b073f90db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + + public Page getProducts(Pageable pageable) { + return productRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 000000000..14722b017 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..4f548c95e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..5ac307971 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,85 @@ +package com.loopers.domain.product; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@SpringBootTest +public class ProductServiceIntegrationTest { + + @Autowired + ProductService productService; + + @MockitoSpyBean + ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ƒํ’ˆ ์กฐํšŒ ํ•  ๋•Œ,") + @Nested + class Get { + + @DisplayName("๊ธฐ๋ณธ ์ •๋ ฌ(์ตœ์‹ ์ˆœ)๋กœ ์ฒซ ํŽ˜์ด์ง€ ์กฐํšŒ ์‹œ, ํŽ˜์ด์ง• ์ •๋ณด์™€ ์ƒ์„ธ ํ•„๋“œ(์ด๋ฆ„/์„ค๋ช…/๊ฐ€๊ฒฉ/์žฌ๊ณ )๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_productList_whenValidIdIsProvided() { + + // arrange + for (int i = 1; i <= 25; i++) { + Product product = new Product( + 1l, + "์ƒํ’ˆ๋ช…" + i, + "์„ค๋ช…" + i, + 1000 * i, + 10 * i + ); + productRepository.save(product); + } + + // act + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + Page resultsPage = productService.getProducts(pageable); + + // assert + assertAll( + () -> assertEquals(20, resultsPage.getSize(), "ํ˜„์žฌ ํŽ˜์ด์ง€ ์‚ฌ์ด์ฆˆ ๊ฒ€์ฆ(20๊ฐœ)"), + () -> assertEquals(2, resultsPage.getTotalPages(), "์ด ํŽ˜์ด์ง€ ์ˆ˜(25๊ฐœ / 20๊ฐœ์”ฉ ์ด 2๊ฐœ)."), + () -> assertEquals(25, resultsPage.getTotalElements(), "์ด ์ƒํ’ˆ ๊ฐœ์ˆ˜(25๊ฐœ)"), + () -> assertTrue(resultsPage.hasNext(), "๋‹ค์Œ ํŽ˜์ด์ง€ ์—ฌ๋ถ€(true)"), + + () -> assertEquals("์ƒํ’ˆ๋ช…25", resultsPage.getContent().get(0).getName(), "๊ฐ€์žฅ ์ตœ์‹  ์ƒํ’ˆ(์ƒํ’ˆ๋ช…25)"), + () -> assertEquals("์ƒํ’ˆ๋ช…6", resultsPage.getContent().get(19).getName(), "20๋ฒˆ์งธ ์ƒํ’ˆ(์ƒํ’ˆ๋ช…6)"), + + () -> { + Product firstProduct = resultsPage.getContent().get(0); + assertAll("์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ์ƒ์„ธ ํ•„๋“œ ๊ฒ€์ฆ", + () -> assertEquals("์ƒํ’ˆ๋ช…25", firstProduct.getName()), + () -> assertEquals("์„ค๋ช…25", firstProduct.getDescription()), + () -> assertEquals(25000, firstProduct.getPrice()), + () -> assertEquals(250, firstProduct.getStock()), + () -> assertEquals(1L, firstProduct.getBrandId()) + ); + } + + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index c1719255c..81a185020 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -69,7 +69,7 @@ void shouldThrowException_whenDescriptionIsBlank() { void shouldThrowException_whenPriceIsNegative() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(1L, "", "description", -1, 100); + new Product(1L, "name", "description", -1, 100); }); // assert From 8ceebb8fce9403ad9139198852fe92fdc5f7db12 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 13 Nov 2025 11:23:24 +0900 Subject: [PATCH 072/164] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductRepository.java | 3 ++ .../domain/product/ProductService.java | 6 +++ .../product/ProductRepositoryImpl.java | 6 +++ .../ProductServiceIntegrationTest.java | 48 ++++++++++++++++++- 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index a89a6b87b..f03b7ff4b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -8,4 +9,6 @@ public interface ProductRepository { Product save(Product product); Page findAll(Pageable pageable); + + Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index b073f90db..d8e5ab9e2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,5 +1,7 @@ package com.loopers.domain.product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,4 +17,8 @@ public class ProductService { public Page getProducts(Pageable pageable) { return productRepository.findAll(pageable); } + + public Product getProduct(Long id) { + return productRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 4f548c95e..7584d50a9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -22,4 +23,9 @@ public Product save(Product product) { public Page findAll(Pageable pageable) { return productJpaRepository.findAll(pageable); } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index 5ac307971..b585b0771 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -1,9 +1,13 @@ package com.loopers.domain.product; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -36,7 +40,7 @@ void tearDown() { @DisplayName("์ƒํ’ˆ ์กฐํšŒ ํ•  ๋•Œ,") @Nested - class Get { + class GetList { @DisplayName("๊ธฐ๋ณธ ์ •๋ ฌ(์ตœ์‹ ์ˆœ)๋กœ ์ฒซ ํŽ˜์ด์ง€ ์กฐํšŒ ์‹œ, ํŽ˜์ด์ง• ์ •๋ณด์™€ ์ƒ์„ธ ํ•„๋“œ(์ด๋ฆ„/์„ค๋ช…/๊ฐ€๊ฒฉ/์žฌ๊ณ )๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test @@ -78,7 +82,49 @@ void return_productList_whenValidIdIsProvided() { () -> assertEquals(1L, firstProduct.getBrandId()) ); } + ); + } + } + + @DisplayName("์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ") + @Nested + class Get { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด(์ด๋ฆ„, ๊ฐ€๊ฒฉ, ๋ธŒ๋žœ๋“œ๋ช…, ์„ค๋ช…, ์žฌ๊ณ )๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_productInfo_whenProductExists() { + // arrange + Product savedProduct = productRepository.save(new Product( + 1L, "์ƒํ’ˆ๋ช…", "์„ค๋ช…", 50000, 5 + )); + + // act + Product result = productService.getProduct(savedProduct.getId()); + + // assert + assertAll("์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด ๊ฒ€์ฆ", + () -> assertEquals(savedProduct.getId(), result.getId()), + () -> assertEquals("์ƒํ’ˆ๋ช…", result.getName()), + () -> assertEquals("์„ค๋ช…", result.getDescription()), + () -> assertEquals(50000, result.getPrice()), + () -> assertEquals(5, result.getStock()), + () -> assertEquals(1L, result.getBrandId()) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ ID๋กœ ์กฐํšŒ ์‹œ 404 Not Found ์—๋Ÿฌ์™€ ์˜ˆ์™ธ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void throws_exception_whenProductNotFound() { + // arrange + long nonExistentId = 9999L; + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + productService.getProduct(nonExistentId); + }); + assertAll("์˜ˆ์™ธ ๊ฒ€์ฆ", + () -> assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND) ); } } From aaea47aa8d2598785e6bdfc462bbd20ab5999acc Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 13 Nov 2025 14:45:43 +0900 Subject: [PATCH 073/164] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/Order.java | 64 +++++++++++++++ .../com/loopers/domain/order/OrderItem.java | 29 +++++++ .../com/loopers/domain/order/OrderTest.java | 80 +++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..f195a7075 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,64 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.util.List; + +@Entity +@Table(name = "order") +public class Order extends BaseEntity { + + private Long userId; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "order_id") + private List orderItems = new java.util.ArrayList<>(); + + protected Order() { + } + + public Order(Long userId, List orderItems) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + if (orderItems == null || orderItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์•„์ดํ…œ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + this.userId = userId; + this.orderItems = orderItems; + } + + public void addOrderItem(Product product, int quantity) { + + if (product == null) { + throw new IllegalArgumentException("์ƒํ’ˆ ์ •๋ณด๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค"); + } + if (quantity <= 0) { + throw new IllegalArgumentException("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค"); + } + if (product.getStock() < quantity) { + throw new IllegalArgumentException("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + } + + orderItems.add(new OrderItem(product, quantity)); + + } + + public List getOrderItems() { + return orderItems; + } + + public long calculateTotalAmount() { + return orderItems.stream() + .mapToLong(OrderItem::calculateAmount) + .sum(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..379304e92 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,29 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Product; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_item") +public class OrderItem extends BaseEntity { + + private Long productId; + private Integer quantity; + private Long price; + + + public OrderItem(Product product, int quantity) { + if(product.getStock() < quantity){ + throw new IllegalArgumentException("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + } + this.productId = product.getId(); + this.quantity = quantity; + this.price = product.getPrice(); + } + + public long calculateAmount() { + return price * quantity; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..13f83a5f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OrderTest { + + @Test + @DisplayName("์œ ํšจํ•œ ์ƒํ’ˆ๊ณผ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•˜๋ฉด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") + void createOrder_success() { + // arrange + Product product1 = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 30000, 15); + Product product2 = new Product(2L, "ํŒฌ์ธ ", "์„ค๋ช…", 50000, 16); + + OrderItem item1 = new OrderItem(product1, 2); + OrderItem item2 = new OrderItem(product2, 1); + + // act + Order order = new Order(1L, List.of(item1, item2)); + + // assert + assertThat(order.getOrderItems()).hasSize(2); + assertThat(order.calculateTotalAmount()).isEqualTo(30000 * 2 + 50000 * 1); + } + + @Test + @DisplayName("์ƒํ’ˆ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋น„์–ด ์žˆ๋Š” ๊ฒฝ์šฐ ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void createOrder_fail_dueToEmptyItems() { + // when & then + assertThatThrownBy(() -> new Order(1L, List.of())) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์ฃผ๋ฌธ ์•„์ดํ…œ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void createOrder_fail_dueToNullUserId() { + Product product = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 30000, 10); + OrderItem item = new OrderItem(product, 1); + + assertThatThrownBy(() -> new Order(null, List.of(item))) + .isInstanceOf(CoreException.class) + .hasMessageContaining("์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + + @Test + @DisplayName("์ƒํ’ˆ ์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฃผ๋ฌธ ์•„์ดํ…œ ์ƒ์„ฑ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void createOrderItem_fail_dueToStock() { + // given + Product product = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 30000, 2); + + // when & then + assertThatThrownBy(() -> new OrderItem(product, 3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + } + + @Test + @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก์ด ์ •ํ™•ํžˆ ๊ณ„์‚ฐ๋œ๋‹ค.") + void calculateTotalAmount_success() { + // given + Product productA = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 10000, 10); + Product productB = new Product(2L, "์ฒญ๋ฐ”์ง€", "์„ค๋ช…", 20000, 10); + + OrderItem itemA = new OrderItem(productA, 3); // 3๋งŒ + OrderItem itemB = new OrderItem(productB, 2); // 4๋งŒ + + Order order = new Order(1L, List.of(itemA, itemB)); + + // then + assertThat(order.calculateTotalAmount()).isEqualTo(70000); + } +} From 3637e2e008e0c9a2f1cba41e770a6afc2a4e9fef Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 13 Nov 2025 18:55:05 +0900 Subject: [PATCH 074/164] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/Order.java | 2 +- .../com/loopers/domain/order/OrderItem.java | 4 +- .../loopers/domain/order/OrderRepository.java | 9 +++ .../loopers/domain/order/OrderService.java | 16 ++++ .../order/OrderJpaRepository.java | 8 ++ .../order/OrderRepositoryImpl.java | 25 ++++++ .../order/OrderServiceIntegrationTest.java | 77 +++++++++++++++++++ 7 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index f195a7075..dab03d9c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -12,7 +12,7 @@ import java.util.List; @Entity -@Table(name = "order") +@Table(name = "orders") public class Order extends BaseEntity { private Long userId; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 379304e92..70f1db812 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -13,9 +13,11 @@ public class OrderItem extends BaseEntity { private Integer quantity; private Long price; + protected OrderItem() { + } public OrderItem(Product product, int quantity) { - if(product.getStock() < quantity){ + if (product.getStock() < quantity) { throw new IllegalArgumentException("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); } this.productId = product.getId(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..12302f7cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +import java.util.Optional; + +public interface OrderRepository { + Order save(Order order); + + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..39a9c3328 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,16 @@ +package com.loopers.domain.order; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + + public Order createOrder(Long userId, List orderItems) { + return orderRepository.save(new Order(userId, orderItems)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java new file mode 100644 index 000000000..7f8f9b969 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..de50162d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..fb85299df --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,77 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.User.Gender; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@SpringBootTest +class OrderServiceIntegrationTest { + + @Autowired + OrderService orderService; + + @MockitoSpyBean + OrderRepository orderRepository; + + @MockitoSpyBean + UserRepository userRepository; + + @MockitoSpyBean + ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ฃผ๋ฌธ ํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ ID๋“ค๋กœ ์ฃผ๋ฌธ ์š”์ฒญ ์‹œ ์ฃผ๋ฌธ์ด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋˜๊ณ  ์ €์žฅ๋œ๋‹ค.") + @Test + void createOrder_whenValidProductIds_thenOrderIsCreatedAndSaved() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); + Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); + + List itemRequests = List.of( + new OrderItem(productA, 2), + new OrderItem(productB, 1) + ); + + // act + Order createdOrder = orderService.createOrder(user.getId(), itemRequests); + + // assert + assertAll( + () -> assertThat(createdOrder.getId()).isNotNull(), + () -> assertThat(createdOrder.getOrderItems()).hasSize(2), + () -> assertThat(createdOrder.getOrderItems()) + .extracting("productId") + .containsExactlyInAnyOrder(productA.getId(), productB.getId()), + () -> assertThat(createdOrder.getOrderItems()) + .extracting("quantity") + .containsExactlyInAnyOrder(2, 1) + ); + } + } +} From 914900c98a17b82f38016bfbc7b6486bf72d86ee Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 13 Nov 2025 20:52:19 +0900 Subject: [PATCH 075/164] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/Order.java | 4 + .../loopers/domain/order/OrderRepository.java | 3 + .../loopers/domain/order/OrderService.java | 15 +++ .../order/OrderJpaRepository.java | 2 + .../order/OrderRepositoryImpl.java | 10 +- .../order/OrderServiceIntegrationTest.java | 116 +++++++++++++++++- 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index dab03d9c1..032508100 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -36,6 +36,10 @@ public Order(Long userId, List orderItems) { this.orderItems = orderItems; } + public Long getUserId() { + return userId; + } + public void addOrderItem(Product product, int quantity) { if (product == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 12302f7cb..3e770f148 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -1,9 +1,12 @@ package com.loopers.domain.order; +import java.util.List; import java.util.Optional; public interface OrderRepository { Order save(Order order); Optional findById(Long id); + + List findByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 39a9c3328..8fb97676a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,5 +1,8 @@ package com.loopers.domain.order; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,4 +16,16 @@ public class OrderService { public Order createOrder(Long userId, List orderItems) { return orderRepository.save(new Order(userId, orderItems)); } + + public List getOrders(Long userId) { + List orders = orderRepository.findByUserId(userId); +// if (orders.isEmpty()) { +// throw new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); +// } + return orders; + } + + public Order getOrder(Long id) { + return orderRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 7f8f9b969..3e24c872d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,8 +1,10 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderJpaRepository extends JpaRepository { + List findByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index de50162d1..8011c185b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -18,8 +19,15 @@ public Order save(Order order) { return orderJpaRepository.save(order); } + @Override + public List findByUserId(Long userId) { + return orderJpaRepository.findByUserId(userId); + } + @Override public Optional findById(Long id) { - return Optional.empty(); + return orderJpaRepository.findById(id); } + + } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java index fb85299df..279a5af50 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -2,12 +2,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; import com.loopers.domain.user.User.Gender; import com.loopers.domain.user.UserRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; import java.util.List; import org.junit.jupiter.api.AfterEach; @@ -17,8 +20,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Transactional; @SpringBootTest +@Transactional class OrderServiceIntegrationTest { @Autowired @@ -53,13 +58,13 @@ void createOrder_whenValidProductIds_thenOrderIsCreatedAndSaved() { Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); - List itemRequests = List.of( + List orderItems = List.of( new OrderItem(productA, 2), new OrderItem(productB, 1) ); // act - Order createdOrder = orderService.createOrder(user.getId(), itemRequests); + Order createdOrder = orderService.createOrder(user.getId(), orderItems); // assert assertAll( @@ -74,4 +79,111 @@ void createOrder_whenValidProductIds_thenOrderIsCreatedAndSaved() { ); } } + + @DisplayName("์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํšŒ") + @Nested + class GetList { + + @DisplayName("์กด์žฌํ•˜๋Š” ์œ ์ € ID๋กœ ์กฐํšŒํ•˜๋ฉด ์ฃผ๋ฌธ ๋ชฉ๋ก์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_orderList_whenUserHasOrders() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); + Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); + + List orderItems = List.of( + new OrderItem(productA, 2), + new OrderItem(productB, 1) + ); + + Product productC = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…3", "์„ค๋ช…3", 20000, 10)); + Product productD = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…4", "์„ค๋ช…4", 10000, 8)); + + List orderItems2 = List.of( + new OrderItem(productC, 1), + new OrderItem(productD, 3) + ); + + orderRepository.save(new Order(user.getId(), orderItems)); + orderRepository.save(new Order(user.getId(), orderItems2)); + + + // act + List results = orderService.getOrders(user.getId()); + + // assert + assertAll( + () -> assertThat(results).hasSize(2), + () -> assertThat(results.get(0).getUserId()).isEqualTo(user.getId()), + () -> assertThat(results.get(0).getOrderItems()).isNotEmpty(), + () -> assertThat(results.get(0).calculateTotalAmount()).isGreaterThan(0) + ); + } + + @DisplayName("ํ•ด๋‹น ์œ ์ €์˜ ์ฃผ๋ฌธ์ด ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_emptyList_whenUserHasNoOrders() { + // arrange + User user = userRepository.save(new User("user", "noorder@email.com", "2025-11-11", Gender.FEMALE)); + + // act + List result = orderService.getOrders(user.getId()); + + // assert + assertAll( + () -> assertThat(result).isEmpty() + ); + } + } + + @DisplayName("๋‹จ์ผ ์ฃผ๋ฌธ ์ƒ์„ธ ์กฐํšŒ") + @Nested + class Get { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ํ•ด๋‹น ์•„์ด๋””์˜ ์ด ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_orderInfo_whenUserHasOrders() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); + Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); + + List orderItems = List.of( + new OrderItem(productA, 2), + new OrderItem(productB, 1) + ); + + Order savedOrder = orderRepository.save(new Order(user.getId(), orderItems)); + + // act + Order result = orderService.getOrder(savedOrder.getId()); + + // assert + assertAll( + () -> assertThat(result.getId()).isEqualTo(savedOrder.getId()), + () -> assertThat(result.getUserId()).isEqualTo(user.getId()), + + () -> assertThat(result.getOrderItems()).hasSize(2), + + () -> assertThat(result.calculateTotalAmount()).isEqualTo(110000) + + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฃผ๋ฌธ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throw_exception_whenOrderNotFound() { + // arrange + Long nonExistentOrderId = 99999L; + + ///act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.getOrder(nonExistentOrderId); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } } From e898b73e0ff77da594a333d96147c8094c5862cc Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 13 Nov 2025 22:24:45 +0900 Subject: [PATCH 076/164] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/like/Like.java | 28 ++++++++++ .../com/loopers/domain/like/LikeTest.java | 56 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..b73f248c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,28 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "like") +public class Like extends BaseEntity { + private Long userId; + private Long productId; + + protected Like() {} + + public Like(Long userId, Long productId) { + super(); + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + this.userId = userId; + this.productId = productId; + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..2e4664fe2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class LikeTest { + + @DisplayName("Like ๊ฐ์ฒด ์ƒ์„ฑ ํ…Œ์ŠคํŠธ") + @Nested + class Create { + + @DisplayName("๋ชจ๋“  ๊ฐ’์ด ์œ ํšจํ•˜๋ฉด Like ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑ์— ์„ฑ๊ณตํ•œ๋‹ค.") + @Test + void create_like_with_valid_data() { + // assert + assertDoesNotThrow(() -> { + new Like(1L, 100L); + }); + } + + @DisplayName("Like ๊ฐ์ฒด ์ƒ์„ฑ ์‹คํŒจ ํ…Œ์ŠคํŠธ") + @Nested + class LikeValidation { + + @Test + @DisplayName("์œ ์ € ID(userId)๊ฐ€ ์—†์œผ๋ฉด(null), Like ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void throwsBadRequest_whenUserIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Like(null, 100L); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @Test + @DisplayName("์ƒํ’ˆ ID(productId)๊ฐ€ ์—†์œผ๋ฉด(null), Like ๊ฐ์ฒด ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") + void throwsBadRequest_whenProductIdIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> { + new Like(1L, null); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + } +} From 9a308a7e4acdff29cbdfc46dbf69ffde6490b1c6 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 14 Nov 2025 08:12:31 +0900 Subject: [PATCH 077/164] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94,=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=B7=A8=EC=86=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/like/Like.java | 16 ++- .../loopers/domain/like/LikeRepository.java | 12 ++ .../com/loopers/domain/like/LikeService.java | 21 ++++ .../like/LikeJpaRepository.java | 12 ++ .../like/LikeRepositoryImpl.java | 31 +++++ .../like/LikeServiceIntegrationTest.java | 116 ++++++++++++++++++ 6 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index b73f248c8..5f908d92f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -5,9 +5,15 @@ import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; @Entity -@Table(name = "like") +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint( + name = "uk_likes_user_product", + columnNames = {"userId", "productId"} + ) +}) public class Like extends BaseEntity { private Long userId; private Long productId; @@ -25,4 +31,12 @@ public Like(Long userId, Long productId) { this.userId = userId; this.productId = productId; } + + public Long getUserId() { + return userId; + } + + public Long getProductId() { + return productId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..4da4d42a2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Like save(Like like); + + void deleteByUserIdAndProductId(Long userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..7ce29d270 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,21 @@ +package com.loopers.domain.like; + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + + public Like createLike(Like like) { + return likeRepository.findByUserIdAndProductId(like.getUserId(), like.getProductId()) + .orElseGet(() -> likeRepository.save(like)); + } + + public void deleteLike(Long userId, Long productId) { + likeRepository.deleteByUserIdAndProductId(userId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java new file mode 100644 index 000000000..a87eed317 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + void deleteByUserIdAndProductId(Long userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..c188604fd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, + Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public void deleteByUserIdAndProductId(Long userId, Long productId) { + likeJpaRepository.deleteByUserIdAndProductId(userId, productId); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..7ba0de74f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.User.Gender; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class LikeServiceIntegrationTest { + + @Autowired + LikeService likeService; + + @MockitoSpyBean + LikeRepository likeRepository; + + @MockitoSpyBean + UserRepository userRepository; + + @MockitoSpyBean + ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ƒํ’ˆ ์ข‹์•„์š” ํ•  ๋•Œ,") + @Nested + class Create { + + @DisplayName("์ •์ƒ์ ์ธ ์š”์ฒญ์ด๋ฉด ์ข‹์•„์š”๊ฐ€ ์ €์žฅ๋œ๋‹ค.") + @Test + void like_success_whenValidRequest() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + // act + Like like = likeService.createLike(new Like(user.getId(), product.getId())); + + // assert + assertAll( + () -> assertThat(like).isNotNull(), + () -> assertThat(like.getUserId()).isEqualTo(user.getId()), + () -> assertThat(like.getProductId()).isEqualTo(product.getId()) + ); + } + + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์— ๋‹ค์‹œ ์š”์ฒญํ•˜๋ฉด, ์ค‘๋ณต ์ €์žฅ๋˜์ง€ ์•Š๊ณ  ๊ธฐ์กด ์ข‹์•„์š” ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.- ๋ฉฑ๋“ฑ์„ฑ") + @Test + void createLike_returnsExisting_whenAlreadyLiked() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + // act + Like firstLike = likeService.createLike(new Like(user.getId(), product.getId())); + Like secondLike = likeService.createLike(new Like(user.getId(), product.getId())); + + // assert + assertAll( + () -> assertThat(secondLike.getId()).isEqualTo(firstLike.getId()) + ); + } + } + + @DisplayName("์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ ํ•  ๋•Œ,") + @Nested + class UnlikeProduct { + + @DisplayName("์ข‹์•„์š” ํ–ˆ๋˜ ์ƒํ’ˆ์„ ์ทจ์†Œํ•˜๋ฉด ๋ฐ์ดํ„ฐ๊ฐ€ ์‚ญ์ œ๋œ๋‹ค.") + @Test + void deleteLike_success_whenPreviouslyLiked() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + likeRepository.save(new Like(user.getId(), product.getId())); + + // act + likeService.deleteLike(user.getId(), product.getId()); + + // assert + assertThat(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())).isEmpty(); + } + + @DisplayName("์ข‹์•„์š” ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์„ ์ทจ์†Œํ•˜๋ฉด ์˜ˆ์™ธ ์—†์ด ์ •์ƒ ์ข…๋ฃŒ๋œ๋‹ค.") + @Test + void unlike_doNothing_whenNotLiked() { + // arrange + User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + // act & assert + assertDoesNotThrow(() -> likeService.deleteLike(user.getId(), product.getId())); + } + } +} From 48db0f3209107d2308d50e15a489e3133ccc615e Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 14 Nov 2025 11:12:49 +0900 Subject: [PATCH 078/164] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EC=9D=98=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20VO=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EB=93=A4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/point/Point.java | 41 +++++++++++++++++++ .../loopers/domain/point/PointService.java | 4 +- .../java/com/loopers/domain/user/User.java | 33 ++++++--------- .../api/point/PointV1Controller.java | 3 +- .../interfaces/api/point/PointV1Dto.java | 5 ++- .../point/PointServiceIntegrationTest.java | 11 +++-- .../com/loopers/domain/user/UserTest.java | 11 +++-- .../interfaces/api/PointV1ApiE2ETest.java | 3 +- 8 files changed, 72 insertions(+), 39 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java new file mode 100644 index 000000000..5f0bca00d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -0,0 +1,41 @@ +package com.loopers.domain.point; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.EqualsAndHashCode; + +@Embeddable +@EqualsAndHashCode +public class Point { + + private int amount; + + protected Point() { + } + + public Point(int amount) { + validate(amount); + this.amount = amount; + } + + public Point add(int value) { + if(value <= 0) throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ๊ธˆ์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + return new Point(this.amount + value); + } + + public Point subtract(int value) { + if (this.amount < value) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ ์ž”์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + return new Point(this.amount - value); + } + + private void validate(int amount) { + if (amount < 0) throw new CoreException(ErrorType.BAD_REQUEST); + } + + public int getAmount() { + return amount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 2d71882e7..f92c93c3c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -15,7 +15,7 @@ public class PointService { private final UserRepository userRepository; - public Integer getPoint(String userId) { + public Point getPoint(String userId) { return userRepository.findByUserId(userId) .map(User::getPoint) .orElse(null); @@ -25,7 +25,7 @@ public Integer getPoint(String userId) { public PointResponse charge(String userId, int amount) { User user = userRepository.findByUserId(userId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - int chargePoint = user.chargePoint(amount); + Point chargePoint = user.getPoint().add(amount); return PointResponse.from(chargePoint); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 057481794..9e9d22467 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,31 +1,29 @@ package com.loopers.domain.user; +import com.loopers.domain.BaseEntity; +import com.loopers.domain.point.Point; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.LocalDate; import java.time.format.DateTimeParseException; @Entity @Table(name = "user") -public class User { +public class User extends BaseEntity { private static final String ID_PATTERN = "^[a-zA-Z0-9]{1,10}$"; private static final String EMAIL_PATTERN = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"; private static final String BIRTHDATE_PATTERN = "^\\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])$"; - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - String userId; - String email; - String birthdate; + private String userId; + private String email; + private String birthdate; private Gender gender; - private int point; + @Embedded + private Point point; public enum Gender { MALE, FEMALE @@ -62,10 +60,10 @@ public User(String userId, String email, String birthdate, Gender gender) { this.email = email; this.birthdate = birthdate; this.gender = gender; - this.point = 0; + this.point = new Point(0); } - public User(String userId, String email, String birthdate, Gender gender, int point) { + public User(String userId, String email, String birthdate, Gender gender, Point point) { this.userId = userId; this.email = email; this.birthdate = birthdate; @@ -89,15 +87,8 @@ public Gender getGender() { return gender; } - public int getPoint() { + public Point getPoint() { return point; } - public int chargePoint(int amount) { - if (amount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ๊ธˆ์•ก์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); - } - - return this.point += amount; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index da2a84192..b660daad2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.point; +import com.loopers.domain.point.Point; import com.loopers.domain.point.PointService; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.point.PointV1Dto.ChargePointsRequest; @@ -28,7 +29,7 @@ public ApiResponse getPoint(@RequestHeader(value = "X-USER-ID", r if (userId == null || userId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "ํ•„์ˆ˜ ํ—ค๋”์ธ X-USER-ID๊ฐ€ ์—†๊ฑฐ๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); } - Integer point = pointService.getPoint(userId); + Point point = pointService.getPoint(userId); PointResponse response = new PointResponse(point); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java index a5838898e..93a11e1a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -1,12 +1,13 @@ package com.loopers.interfaces.api.point; +import com.loopers.domain.point.Point; import jakarta.validation.constraints.NotNull; public class PointV1Dto { - public record PointResponse(Integer point) { + public record PointResponse(Point point) { - public static PointResponse from(int chargePoint) { + public static PointResponse from(Point chargePoint) { return new PointResponse( chargePoint ); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index 060381833..da2941983 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -53,19 +53,18 @@ class Get { void return_user_point_whenValidIdIsProvided() { // arrange String findId = "findId"; - int expectedPoint = 10; - User existingUser = new User(findId, VALID_EMAIL, VALID_BIRTHDATE, VALID_GENDER, - expectedPoint); + Point expectedPoint = new Point(10); + User existingUser = new User(findId, VALID_EMAIL, VALID_BIRTHDATE, VALID_GENDER, expectedPoint); // act userRepository.save(existingUser); - Integer point = pointService.getPoint(findId); + Point point = pointService.getPoint(findId); // assert assertAll( () -> assertNotNull(point), - () -> assertEquals(expectedPoint, point.intValue()) + () -> assertEquals(expectedPoint.getAmount(), point.getAmount()) ); } @@ -76,7 +75,7 @@ void return_null_whenInvalidIdIsProvided() { String invalidId = "non-existent-user-id"; // act - Integer point = pointService.getPoint(invalidId); + Point point = pointService.getPoint(invalidId); // assert assertNull(point); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index ebd5896ac..ce2fdd8e5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; +import com.loopers.domain.point.Point; import com.loopers.domain.user.User.Gender; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -21,8 +22,6 @@ class UserTest { @Nested class Create { - - @DisplayName("๋ชจ๋“  ๊ฐ’์ด ์œ ํšจํ•˜๋ฉด User ๊ฐ์ฒด ์ƒ์„ฑ์— ์„ฑ๊ณตํ•œ๋‹ค.") @Test void create_user_with_valid_data() { @@ -161,10 +160,10 @@ void chargePoint_with_positive_amount() { // arrange User user = createUser(VALID_USER_ID, VALID_EMAIL, VALID_BIRTHDATE, VALID_GENDER); int chargeAmount = 100; - int expectedPoint = 100; + Point expectedPoint = new Point(100); // act - int chargePoint = user.chargePoint(chargeAmount); + Point chargePoint = user.getPoint().add(chargeAmount); // assert assertEquals(expectedPoint, chargePoint); @@ -173,11 +172,11 @@ void chargePoint_with_positive_amount() { private void assertChargePointFails(int invalidAmount) { // arrange User user = createUser(VALID_USER_ID, VALID_EMAIL, VALID_BIRTHDATE, VALID_GENDER); - int initialPoint = user.getPoint(); + Point initialPoint = user.getPoint(); // act CoreException exception = assertThrows(CoreException.class, () -> { - user.chargePoint(invalidAmount); + user.getPoint().add(invalidAmount); }); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java index 2aa08543a..525b0cf29 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.loopers.domain.point.Point; import com.loopers.domain.user.User; import com.loopers.domain.user.User.Gender; import com.loopers.domain.user.UserRepository; @@ -59,7 +60,7 @@ class Get { @Test void returnsPoint_whenHeaderIsProvided() { // arrange - int expectedPoint = 10; + Point expectedPoint = new Point(10); User user = userRepository.save( new User("validId10", "valid@email.com", "2025-10-28", Gender.FEMALE, expectedPoint) From f44f9126ab20e4eb2cf2058241177c29f55b751d Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 14 Nov 2025 12:40:08 +0900 Subject: [PATCH 079/164] =?UTF-8?q?feature:=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20Facade=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 23 +++++ .../loopers/application/brand/BrandInfo.java | 30 ++++++ .../loopers/domain/product/ProductInfo.java | 15 +++ .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 7 ++ .../product/ProductJpaRepository.java | 3 + .../product/ProductRepositoryImpl.java | 5 + .../brand/BrandFacadeIntegrationTest.java | 91 +++++++++++++++++++ 8 files changed, 176 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..75bd160de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,23 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + private final BrandService brandService; + private final ProductService productService; + + public BrandInfo getBrandInfo(long brandId, Pageable pageable) { + Brand brand = brandService.getBrand(brandId); + Page products = productService.getProductsByBrandId(brandId, pageable); + return BrandInfo.from(brand, products); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..7ac546841 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,30 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductInfo; +import org.springframework.data.domain.Page; + +public record BrandInfo( + // 1. Brand ์—”ํ‹ฐํ‹ฐ ๋Œ€์‹  ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์ถ”์ถœ + Long brandId, + String brandName, + String brandDescription, + + // 2. Page ๋Œ€์‹  Page ํฌํ•จ + Page products +) { + + public static BrandInfo from(Brand brand, Page products) { + + // 3. Page๋ฅผ Page๋กœ ๋ณ€ํ™˜ (ํ•ต์‹ฌ) + Page productInfos = products.map(ProductInfo::from); + + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getDescription(), + productInfos + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java new file mode 100644 index 000000000..c9a3b6c7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java @@ -0,0 +1,15 @@ +package com.loopers.domain.product; + +public record ProductInfo( + Long id, + String name, + long price +) { + public static ProductInfo from(Product product) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getPrice() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index f03b7ff4b..7bd29a48b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -11,4 +11,6 @@ public interface ProductRepository { Page findAll(Pageable pageable); Optional findById(Long id); + + Page findByBrandId(Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index d8e5ab9e2..e4b68e04f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -2,6 +2,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -21,4 +22,10 @@ public Page getProducts(Pageable pageable) { public Product getProduct(Long id) { return productRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + public Page getProductsByBrandId(Long brandId, Pageable pageable) { + + return productRepository.findByBrandId(brandId, pageable); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 14722b017..ad952361a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,8 +1,11 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ProductJpaRepository extends JpaRepository { + Page findByBrandId(Long brandId, Pageable pageable); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 7584d50a9..a1880faff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -28,4 +28,9 @@ public Page findAll(Pageable pageable) { public Optional findById(Long id) { return productJpaRepository.findById(id); } + + @Override + public Page findByBrandId(Long brandId, Pageable pageable) { + return productJpaRepository.findByBrandId(brandId, pageable); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java new file mode 100644 index 000000000..f6316bd23 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java @@ -0,0 +1,91 @@ +package com.loopers.application.brand; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class BrandFacadeIntegrationTest { + + @Autowired + BrandFacade brandFacade; + + @Autowired + BrandRepository brandRepository; + + @Autowired + ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("๋ธŒ๋žœ๋“œ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ ์‹œ") + @Nested + class GetBrandInfo { + + @DisplayName("๋ธŒ๋žœ๋“œ์™€ ์ƒํ’ˆ์ด ์กด์žฌํ•˜๋ฉด, BrandInfo DTO๊ฐ€ ์ •์ƒ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_brandInfo_whenBrandAndProductsExist() { + // arrange + Brand brand = brandRepository.save(new Brand("Nike", "Just Do It.")); + + productRepository.save(new Product(brand.getId(), "Air Max", "์„ค๋ช…1", 150000, 10)); + productRepository.save(new Product(brand.getId(), "Air Force", "์„ค๋ช…2", 130000, 10)); + productRepository.save(new Product(brand.getId(), "Jordan", "์„ค๋ช…3", 200000, 10)); + + Pageable pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); + + // act + BrandInfo result = brandFacade.getBrandInfo(brand.getId(), pageable); + + // assert + assertAll( + () -> assertThat(result.brandId()).isEqualTo(brand.getId()), + () -> assertThat(result.brandName()).isEqualTo("Nike"), + + () -> assertThat(result.products().getTotalElements()).isEqualTo(3), + () -> assertThat(result.products().getTotalPages()).isEqualTo(2), + () -> assertThat(result.products().getNumber()).isEqualTo(0) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ธŒ๋žœ๋“œ ID๋กœ ์กฐํšŒํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void throw_exception_whenBrandNotFound() { + // arrange + Long nonExistentBrandId = 99999L; + Pageable pageable = PageRequest.of(0, 10); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + brandFacade.getBrandInfo(nonExistentBrandId, pageable); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} + From 289120fe79c2d16ca24b593d4a1f9cb203af0990 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 14 Nov 2025 13:50:50 +0900 Subject: [PATCH 080/164] =?UTF-8?q?feature:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20Facade=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/brand/BrandInfo.java | 5 +- .../application/product/ProductFacade.java | 26 ++++++ .../application/product/ProductInfo.java | 28 +++++++ .../loopers/domain/product/ProductInfo.java | 15 ---- .../product/ProductFacadeIntegrationTest.java | 79 +++++++++++++++++++ .../ProductServiceIntegrationTest.java | 46 +++++++++++ 6 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java index 7ac546841..296d26d7e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -2,22 +2,19 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductInfo; +import com.loopers.application.product.ProductInfo; import org.springframework.data.domain.Page; public record BrandInfo( - // 1. Brand ์—”ํ‹ฐํ‹ฐ ๋Œ€์‹  ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ์ถ”์ถœ Long brandId, String brandName, String brandDescription, - // 2. Page ๋Œ€์‹  Page ํฌํ•จ Page products ) { public static BrandInfo from(Brand brand, Page products) { - // 3. Page๋ฅผ Page๋กœ ๋ณ€ํ™˜ (ํ•ต์‹ฌ) Page productInfos = products.map(ProductInfo::from); return new BrandInfo( diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..7d7aed779 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,26 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + private final ProductService productService; + private final BrandService brandService; + + public Page getProductInfo(Pageable pageable) { + Page products = productService.getProducts(pageable); + return products.map(product -> { + String brandName = brandService.getBrand(product.getBrandId()) + .getName(); + return ProductInfo.from(product, brandName); + }); + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..9e9a18a09 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,28 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Product; + +public record ProductInfo( + Long id, + String name, + long price, + String brandName +) { + public static ProductInfo from(Product product) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getPrice(), + null + ); + } + + public static ProductInfo from(Product product, String brandName) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getPrice(), + brandName + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java deleted file mode 100644 index c9a3b6c7e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductInfo.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.loopers.domain.product; - -public record ProductInfo( - Long id, - String name, - long price -) { - public static ProductInfo from(Product product) { - return new ProductInfo( - product.getId(), - product.getName(), - product.getPrice() - ); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java new file mode 100644 index 000000000..b27bd27a9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java @@ -0,0 +1,79 @@ +package com.loopers.application.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class ProductFacadeIntegrationTest { + @Autowired + ProductFacade productFacade; + + @Autowired + ProductRepository productRepository; + + @Autowired + BrandRepository brandRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ ์‹œ, ๊ฐ ์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ ์ด๋ฆ„์„ ํฌํ•จํ•˜์—ฌ DTO ํŽ˜์ด์ง€๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void return_productInfoPage_withBrandNames() { + // arrange + Brand brandA = brandRepository.save(new Brand("BrandA", "๋ธŒ๋žœ๋“œA")); + Brand brandB = brandRepository.save(new Brand("BrandB", "๋ธŒ๋žœ๋“œB")); + + productRepository.save(new Product(brandA.getId(), "Product A", "์„ค๋ช…", 20000, 10)); + productRepository.save(new Product(brandA.getId(), "Product B", "์„ค๋ช…", 15000, 10)); + productRepository.save(new Product(brandB.getId(), "Product C", "์„ค๋ช…", 10000, 10)); + productRepository.save(new Product(brandB.getId(), "Product D", "์„ค๋ช…", 30000, 10)); + + + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + + // act + Page result = productFacade.getProductInfo(pageable); + + // assert + assertAll( + () -> assertThat(result.getTotalElements()).isEqualTo(4), + () -> assertThat(result.getNumber()).isEqualTo(0) + ); + } + + @DisplayName("์ƒํ’ˆ์ด ์—†์„ ๊ฒฝ์šฐ ๋นˆ ํŽ˜์ด์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void return_emptyPage_whenNoProductsExist() { + // arrange + Pageable pageable = PageRequest.of(0, 10); + + // act + Page result = productFacade.getProductInfo(pageable); + + // assert + assertThat(result.isEmpty()).isTrue(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index b585b0771..e827ec9dd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -84,6 +84,52 @@ void return_productList_whenValidIdIsProvided() { } ); } + + @DisplayName("๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ ์ •๋ ฌ๋กœ ์ฒซ ํŽ˜์ด์ง€ ์กฐํšŒ ์‹œ, ํŽ˜์ด์ง• ์ •๋ณด์™€ ์ƒ์„ธ ํ•„๋“œ(์ด๋ฆ„/์„ค๋ช…/๊ฐ€๊ฒฉ/์žฌ๊ณ )๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void return_productList_sortedByPriceAsc() { + + // arrange + for (int i = 1; i <= 25; i++) { + Product product = new Product( + 1L, + "์ƒํ’ˆ๋ช…" + i, + "์„ค๋ช…" + i, + 1000 * i, + 10 * i + ); + productRepository.save(product); + } + + // act + Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.ASC, "price")); + Page resultsPage = productService.getProducts(pageable); + + // assert + assertAll( + () -> assertEquals(20, resultsPage.getSize(), "ํ˜„์žฌ ํŽ˜์ด์ง€ ์‚ฌ์ด์ฆˆ ๊ฒ€์ฆ(20๊ฐœ)"), + () -> assertEquals(2, resultsPage.getTotalPages(), "์ด ํŽ˜์ด์ง€ ์ˆ˜(25๊ฐœ / 20๊ฐœ์”ฉ ์ด 2๊ฐœ)."), + () -> assertEquals(25, resultsPage.getTotalElements(), "์ด ์ƒํ’ˆ ๊ฐœ์ˆ˜(25๊ฐœ)"), + () -> assertTrue(resultsPage.hasNext(), "๋‹ค์Œ ํŽ˜์ด์ง€ ์—ฌ๋ถ€(true)"), + + // ๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ โ†’ ๊ฐ€์žฅ ์‹ผ ์ƒํ’ˆ(์ƒํ’ˆ๋ช…1)์ด 0๋ฒˆ index + () -> assertEquals("์ƒํ’ˆ๋ช…1", resultsPage.getContent().get(0).getName(), "๊ฐ€๊ฒฉ์ด ๊ฐ€์žฅ ๋‚ฎ์€ ์ƒํ’ˆ(์ƒํ’ˆ๋ช…1)"), + + // 20๋ฒˆ์งธ ์ƒํ’ˆ์€ ์ƒํ’ˆ๋ช…(20), ๊ฐ€๊ฒฉ(20000) + () -> assertEquals("์ƒํ’ˆ๋ช…20", resultsPage.getContent().get(19).getName(), "20๋ฒˆ์งธ ์ƒํ’ˆ(์ƒํ’ˆ๋ช…20)"), + + () -> { + Product cheapestProduct = resultsPage.getContent().get(0); + assertAll("์ฒซ ๋ฒˆ์งธ ์ƒํ’ˆ ์ƒ์„ธ ํ•„๋“œ ๊ฒ€์ฆ (๊ฐ€๊ฒฉ ์˜ค๋ฆ„์ฐจ์ˆœ)", + () -> assertEquals("์ƒํ’ˆ๋ช…1", cheapestProduct.getName()), + () -> assertEquals("์„ค๋ช…1", cheapestProduct.getDescription()), + () -> assertEquals(1000, cheapestProduct.getPrice()), + () -> assertEquals(10, cheapestProduct.getStock()), + () -> assertEquals(1L, cheapestProduct.getBrandId()) + ); + } + ); + } } @DisplayName("์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ") From d8bba1a8926d6a59322b602508aabc1fe441b71a Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 14 Nov 2025 15:38:00 +0900 Subject: [PATCH 081/164] =?UTF-8?q?feature:=20=EC=A2=85=EC=95=84=EC=9A=94?= =?UTF-8?q?=20Facade=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 28 ++++ .../loopers/application/like/LikeInfo.java | 20 +++ .../loopers/domain/like/LikeRepository.java | 2 + .../com/loopers/domain/like/LikeService.java | 13 +- .../like/LikeJpaRepository.java | 2 + .../like/LikeRepositoryImpl.java | 5 + .../like/LikeFacadeIntegrationTest.java | 142 ++++++++++++++++++ .../like/LikeServiceIntegrationTest.java | 10 +- 8 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..04540b5a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,28 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final UserService userService; + private final ProductService productService; + private final LikeService likeService; + + public LikeInfo like(long userId, long productId) { + Like like = likeService.Like(userId, productId); + long totalLikes = likeService.countLikesByProductId(productId); + return LikeInfo.from(like, totalLikes); + } + + public long unLike(long userId, long productId) { + likeService.unLike(userId, productId); + return likeService.countLikesByProductId(productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..2aa9f7d7c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,20 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; + +public record LikeInfo( + Long id, + Long userId, + Long productId, + long totalLikes +) { + + public static LikeInfo from(Like like, long totalLikes) { + return new LikeInfo( + like.getId(), + like.getUserId(), + like.getProductId(), + totalLikes + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index 4da4d42a2..338eafeb6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -9,4 +9,6 @@ public interface LikeRepository { Like save(Like like); void deleteByUserIdAndProductId(Long userId, Long productId); + + long countByProductId(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 7ce29d270..e5bc6d016 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,6 +1,5 @@ package com.loopers.domain.like; -import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -10,12 +9,16 @@ public class LikeService { private final LikeRepository likeRepository; - public Like createLike(Like like) { - return likeRepository.findByUserIdAndProductId(like.getUserId(), like.getProductId()) - .orElseGet(() -> likeRepository.save(like)); + public Like Like(long userId, long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId) + .orElseGet(() -> likeRepository.save(new Like(userId, productId))); } - public void deleteLike(Long userId, Long productId) { + public void unLike(Long userId, Long productId) { likeRepository.deleteByUserIdAndProductId(userId, productId); } + + public long countLikesByProductId(Long productId) { + return likeRepository.countByProductId(productId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index a87eed317..6c94776ef 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -9,4 +9,6 @@ public interface LikeJpaRepository extends JpaRepository { Optional findByUserIdAndProductId(Long userId, Long productId); void deleteByUserIdAndProductId(Long userId, Long productId); + + long countByProductId(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index c188604fd..c20f8f99e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -28,4 +28,9 @@ public void deleteByUserIdAndProductId(Long userId, Long productId) { likeJpaRepository.deleteByUserIdAndProductId(userId, productId); } + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } + } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java new file mode 100644 index 000000000..b731026ca --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -0,0 +1,142 @@ +package com.loopers.application.like; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.User.Gender; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class LikeFacadeIntegrationTest { + + @Autowired + LikeFacade likeFacade; + + @Autowired + LikeRepository likeRepository; + + @Autowired + UserRepository userRepository; + + @Autowired + ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ข‹์•„์š” ์š”์ฒญ ์‹œ") + @Nested + class LikeProduct { + + @DisplayName("์ฒซ ์ข‹์•„์š” ์š”์ฒญ ์‹œ, like info๊ฐ€ ๋ฐ˜ํ™˜๋˜๊ณ  DB์— ์ €์žฅ๋œ๋‹ค.") + @Test + void like_success_whenFirstLike() { + // arrange + User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + // act + LikeInfo result = likeFacade.like(user.getId(), product.getId()); + + // assert + assertAll( + () -> assertThat(result.id()).isNotNull(), + () -> assertThat(result.userId()).isEqualTo(user.getId()), + () -> assertThat(result.productId()).isEqualTo(product.getId()), + + () -> assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1) + ); + } + + @DisplayName("๋‘ ๋ฒˆ์งธ ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„์š” ์š”์ฒญ ์‹œ, ๊ฐฑ์‹  ์ „ ์นด์šดํŠธ(1)๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void like_success_whenSecondLike() { + // arrange + User user1 = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); + User user2 = userRepository.save(new User("userB", "b@email.com", "2025-11-11", Gender.FEMALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + likeFacade.like(user1.getId(), product.getId()); + + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); + + // act + LikeInfo result = likeFacade.like(user2.getId(), product.getId()); + + // assert + assertAll( + () -> assertThat(result.userId()).isEqualTo(user2.getId()), + + () -> assertThat(result.totalLikes()).isEqualTo(2), + () -> assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(2) + ); + } + + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ ์‚ฌ์šฉ์ž๊ฐ€ ๋‹ค์‹œ ์š”์ฒญํ•ด๋„, ๋ฉฑ๋“ฑ์„ฑ์ด ๋ณด์žฅ๋œ๋‹ค.") + @Test + void like_idempotent_whenDuplicateRequest() { + // arrange + User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + likeFacade.like(user.getId(), product.getId()); + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); + + // act + LikeInfo result = likeFacade.like(user.getId(), product.getId()); // ์ค‘๋ณต ํ˜ธ์ถœ + + // assert + assertAll( + () -> assertThat(result.totalLikes()).isEqualTo(1L), + + () -> assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1) + ); + } + } + + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ์š”์ฒญ ์‹œ") + @Nested + class UnlikeProduct { + + @DisplayName("์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ ์ƒํ’ˆ์„ ์ทจ์†Œํ•˜๋ฉด, DB์—์„œ ์‚ญ์ œ๋˜๊ณ  ๊ฐฑ์‹ ๋œ ์นด์šดํŠธ(0)๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void unlike_success_and_returns_updated_count() { + // arrange + User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + + likeFacade.like(user.getId(), product.getId()); + assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); + + // act + long totalLikes = likeFacade.unLike(user.getId(), product.getId()); + + // assert + assertAll( + // 3. ๋ฐ˜ํ™˜๋œ ์นด์šดํŠธ๊ฐ€ 0์ธ์ง€ ํ™•์ธ + () -> assertThat(totalLikes).isZero(), + + // 4. ์‹ค์ œ DB์—์„œ๋„ ์‚ญ์ œ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + () -> assertThat(likeRepository.countByProductId(product.getId())).isZero() + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index 7ba0de74f..3fd330bf2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -55,7 +55,7 @@ void like_success_whenValidRequest() { Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); // act - Like like = likeService.createLike(new Like(user.getId(), product.getId())); + Like like = likeService.Like(user.getId(), product.getId()); // assert assertAll( @@ -73,8 +73,8 @@ void createLike_returnsExisting_whenAlreadyLiked() { Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); // act - Like firstLike = likeService.createLike(new Like(user.getId(), product.getId())); - Like secondLike = likeService.createLike(new Like(user.getId(), product.getId())); + Like firstLike = likeService.Like(user.getId(), product.getId()); + Like secondLike = likeService.Like(user.getId(), product.getId()); // assert assertAll( @@ -96,7 +96,7 @@ void deleteLike_success_whenPreviouslyLiked() { likeRepository.save(new Like(user.getId(), product.getId())); // act - likeService.deleteLike(user.getId(), product.getId()); + likeService.unLike(user.getId(), product.getId()); // assert assertThat(likeRepository.findByUserIdAndProductId(user.getId(), product.getId())).isEmpty(); @@ -110,7 +110,7 @@ void unlike_doNothing_whenNotLiked() { Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); // act & assert - assertDoesNotThrow(() -> likeService.deleteLike(user.getId(), product.getId())); + assertDoesNotThrow(() -> likeService.unLike(user.getId(), product.getId())); } } } From 92c486f599f235907aab43b9d729e7482b33e290 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 14 Nov 2025 16:42:58 +0900 Subject: [PATCH 082/164] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20Facade=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 59 +++++++++++++++++++ .../loopers/application/order/OrderInfo.java | 33 +++++++++++ .../com/loopers/domain/order/OrderItem.java | 12 ++++ .../loopers/domain/order/OrderService.java | 20 ++++++- .../loopers/domain/point/PointService.java | 8 +++ .../com/loopers/domain/product/Product.java | 4 ++ .../domain/product/ProductService.java | 24 ++++++++ .../loopers/domain/user/UserRepository.java | 1 + .../user/UserJpaRepository.java | 1 + .../user/UserRepositoryImpl.java | 5 ++ .../loopers/interfaces/order/OrderV1Dto.java | 16 +++++ 11 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..e7b8edb07 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,59 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; +import com.loopers.interfaces.order.OrderV1Dto.OrderRequest; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + private final ProductService productService; + private final UserService userService; + private final OrderService orderService; + private final PointService pointService; + + @Transactional + public OrderInfo placeOrder(String userId, OrderRequest request) { + User user = userService.getUser(userId); + + List productIds = request.items().stream() + .map(OrderItemRequest::productId) + .toList(); + + List products = productService.getProducts(productIds); + long totalAmount = orderService.calculateTotal(products, request.items()); + + + productService.deductStock(products, request.items()); + + pointService.deductPoint(user.getId(), totalAmount); + + List orderItems = buildOrderItems(products, request.items()); + Order order = orderService.createOrder(user.getId(), orderItems); + + return OrderInfo.from(order); + } + + private List buildOrderItems(List products, List items) { + + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + return items.stream() + .map(i -> new OrderItem(productMap.get(i.productId()), i.quantity())) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..70240ae79 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,33 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import java.util.List; + +public record OrderInfo( + Long orderId, + Long userId, + List items, + long totalAmount +) { + + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getOrderItems().stream().map(OrderItemInfo::from).toList(), + order.calculateTotalAmount() + ); + } + + public record OrderItemInfo(Long productId, int quantity, long price) { + + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getQuantity(), + item.getPrice() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 70f1db812..f278bd70a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -28,4 +28,16 @@ public OrderItem(Product product, int quantity) { public long calculateAmount() { return price * quantity; } + + public Long getProductId() { + return productId; + } + + public Integer getQuantity() { + return quantity; + } + + public Long getPrice() { + return price; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 8fb97676a..947bfea19 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,9 +1,12 @@ package com.loopers.domain.order; import com.loopers.domain.product.Product; +import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -19,13 +22,24 @@ public Order createOrder(Long userId, List orderItems) { public List getOrders(Long userId) { List orders = orderRepository.findByUserId(userId); -// if (orders.isEmpty()) { -// throw new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); -// } + return orders; } public Order getOrder(Long id) { return orderRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + public long calculateTotal(List products, List items) { + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + long total = 0; + + for (OrderItemRequest item : items) { + Product p = productMap.get(item.productId()); + total += p.getPrice() * item.quantity(); + } + return total; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index f92c93c3c..7f6ccbd6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -29,4 +29,12 @@ public PointResponse charge(String userId, int amount) { return PointResponse.from(chargePoint); } + + public void deductPoint(Long userId, long totalAmount) { + long currentPoint = userRepository.findPointById(userId); + + if (currentPoint < totalAmount) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ž”์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index a42814ebc..91bd5cde9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -66,4 +66,8 @@ public long getPrice() { public int getStock() { return stock; } + + public void deductStock(int requestedQty) { + this.stock -= requestedQty; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index e4b68e04f..a36747201 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,8 +1,11 @@ package com.loopers.domain.product; +import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,4 +31,25 @@ public Page getProductsByBrandId(Long brandId, Pageable pageable) { return productRepository.findByBrandId(brandId, pageable); } + + public List getProducts(List productIds) { + return productIds.stream() + .map(this::getProduct) + .toList(); + } + + public void deductStock(List products, List orderItems) { + + Map qtyMap = orderItems.stream() + .collect(Collectors.toMap(OrderItemRequest::productId, OrderItemRequest::quantity)); + + for (Product product : products) { + int requestedQty = qtyMap.get(product.getId()); + if (product.getStock() < requestedQty) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ’ˆ์ ˆ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."); + } + product.deductStock(requestedQty); + } + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 98016da72..20aae68fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -7,4 +7,5 @@ public interface UserRepository { Optional findByUserId(String userId); + long findPointById(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 4515a4e36..e92642bc9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -8,4 +8,5 @@ public interface UserJpaRepository extends JpaRepository { Optional findByUserId(String userId); + long findPointById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 797c3feca..2fcdfb105 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -20,4 +20,9 @@ public User save(User user) { public Optional findByUserId(String userId) { return userJpaRepository.findByUserId(userId); } + + @Override + public long findPointById(Long userId) { + return userJpaRepository.findPointById(userId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java new file mode 100644 index 000000000..74a9c660d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java @@ -0,0 +1,16 @@ +package com.loopers.interfaces.order; + +import java.util.List; + +public class OrderV1Dto { + + public record OrderRequest(List items) { + + } + + public record OrderItemRequest(Long productId, + int quantity + ) { + + } +} From e667a5794b983d45b5a33cdf36a0a0031a67ab39 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Mon, 17 Nov 2025 20:17:21 +0900 Subject: [PATCH 083/164] =?UTF-8?q?refactor:=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=20DB=20=EB=A0=88=EB=B2=A8=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20@Column(nullable=20=3D=20false?= =?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 --- .../src/main/java/com/loopers/domain/brand/Brand.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index cb0944eeb..be0b572ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -3,6 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -10,7 +11,10 @@ @Table(name = "brand") public class Brand extends BaseEntity { + @Column(nullable = false) private String name; + + @Column(nullable = false) private String description; protected Brand() { @@ -22,7 +26,7 @@ public Brand(String name, String description) { } if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + throw new CoreException(ErrorType.BAD_REQUEST, "์„ค๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } this.name = name; From 948e1e03636815a9727bf6bfeffab94cfa9a2064 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Mon, 17 Nov 2025 20:40:03 +0900 Subject: [PATCH 084/164] =?UTF-8?q?refactor:=20userId=EA=B0=80=20null?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=BD=EC=9A=B0=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/order/OrderFacade.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index e7b8edb07..08a8a08c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -10,6 +10,8 @@ import com.loopers.domain.user.UserService; import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; import com.loopers.interfaces.order.OrderV1Dto.OrderRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -27,6 +29,11 @@ public class OrderFacade { @Transactional public OrderInfo placeOrder(String userId, OrderRequest request) { + + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + User user = userService.getUser(userId); List productIds = request.items().stream() From 2fa314da8a12afdedda0b222259f04f8d0c528df Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Mon, 17 Nov 2025 20:55:23 +0900 Subject: [PATCH 085/164] =?UTF-8?q?refactor:=20Order=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B3=A0=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/order/Order.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 032508100..b8b3f9778 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -48,9 +48,6 @@ public void addOrderItem(Product product, int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("์ฃผ๋ฌธ ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค"); } - if (product.getStock() < quantity) { - throw new IllegalArgumentException("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); - } orderItems.add(new OrderItem(product, quantity)); From bc4e2e8989a4b52e308a540378df097c4e78df1c Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 18 Nov 2025 12:08:16 +0900 Subject: [PATCH 086/164] =?UTF-8?q?refactor:=20price->=20VO(Money)?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD,=20DB=20=EB=A0=88=EB=B2=A8=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=EC=9D=84=20=EC=9C=84=ED=95=9C=20@Column=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 --- .../loopers/application/order/OrderInfo.java | 3 +- .../application/product/ProductInfo.java | 3 +- .../java/com/loopers/domain/like/Like.java | 4 +++ .../java/com/loopers/domain/money/Money.java | 30 +++++++++++++++++++ .../java/com/loopers/domain/order/Order.java | 2 ++ .../com/loopers/domain/order/OrderItem.java | 7 +++-- .../loopers/domain/order/OrderService.java | 2 +- .../com/loopers/domain/product/Product.java | 23 +++++++++----- .../brand/BrandFacadeIntegrationTest.java | 7 +++-- .../like/LikeFacadeIntegrationTest.java | 9 +++--- .../product/ProductFacadeIntegrationTest.java | 9 +++--- .../like/LikeServiceIntegrationTest.java | 9 +++--- .../order/OrderServiceIntegrationTest.java | 17 ++++++----- .../com/loopers/domain/order/OrderTest.java | 13 ++++---- .../ProductServiceIntegrationTest.java | 7 +++-- .../loopers/domain/product/ProductTest.java | 13 ++++---- 16 files changed, 107 insertions(+), 51 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 70240ae79..52d951deb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -1,5 +1,6 @@ package com.loopers.application.order; +import com.loopers.domain.money.Money; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import java.util.List; @@ -20,7 +21,7 @@ public static OrderInfo from(Order order) { ); } - public record OrderItemInfo(Long productId, int quantity, long price) { + public record OrderItemInfo(Long productId, int quantity, Money price) { public static OrderItemInfo from(OrderItem item) { return new OrderItemInfo( diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 9e9a18a09..26f278214 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -1,11 +1,12 @@ package com.loopers.application.product; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; public record ProductInfo( Long id, String name, - long price, + Money price, String brandName ) { public static ProductInfo from(Product product) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index 5f908d92f..f79908ce3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -3,6 +3,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -15,7 +16,10 @@ ) }) public class Like extends BaseEntity { + @Column(nullable = false) private Long userId; + + @Column(nullable = false) private Long productId; protected Like() {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java new file mode 100644 index 000000000..50c8a4117 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/money/Money.java @@ -0,0 +1,30 @@ +package com.loopers.domain.money; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Embeddable +@EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Money { + + private Long value; + + public Money(Long value) { + if (value == null) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + if (value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST); + } + this.value = value; + } + + public Long getValue() { + return value; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index b8b3f9778..bae292fbc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -5,6 +5,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; @@ -15,6 +16,7 @@ @Table(name = "orders") public class Order extends BaseEntity { + @Column(nullable = false) private Long userId; @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index f278bd70a..730de0795 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import com.loopers.domain.BaseEntity; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -11,7 +12,7 @@ public class OrderItem extends BaseEntity { private Long productId; private Integer quantity; - private Long price; + private Money price; protected OrderItem() { } @@ -26,7 +27,7 @@ public OrderItem(Product product, int quantity) { } public long calculateAmount() { - return price * quantity; + return price.getValue() * quantity; } public Long getProductId() { @@ -37,7 +38,7 @@ public Integer getQuantity() { return quantity; } - public Long getPrice() { + public Money getPrice() { return price; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 947bfea19..ddc5f672e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -38,7 +38,7 @@ public long calculateTotal(List products, List items) for (OrderItemRequest item : items) { Product p = productMap.get(item.productId()); - total += p.getPrice() * item.quantity(); + total += p.getPrice().getValue() * item.quantity(); } return total; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 91bd5cde9..dc6d607a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -1,8 +1,11 @@ package com.loopers.domain.product; import com.loopers.domain.BaseEntity; +import com.loopers.domain.money.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -10,16 +13,26 @@ @Table(name = "product") public class Product extends BaseEntity { + @Column(nullable = false) private Long brandId; + + @Column(nullable = false) private String name; + + @Column(nullable = false) private String description; - private long price; + + @Column(nullable = false) + @Embedded + private Money price; + + @Column() private int stock; protected Product() { } - public Product(Long brandId, String name, String description, long price, int stock) { + public Product(Long brandId, String name, String description, Money price, int stock) { if (brandId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } @@ -32,10 +45,6 @@ public Product(Long brandId, String name, String description, long price, int st throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์„ค๋ช…์„ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } - if (price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๊ฐ€๊ฒฉ์€ ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); - } - if (stock < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } @@ -59,7 +68,7 @@ public String getDescription() { return description; } - public long getPrice() { + public Money getPrice() { return price; } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java index f6316bd23..3b092e66a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.support.error.CoreException; @@ -52,9 +53,9 @@ void return_brandInfo_whenBrandAndProductsExist() { // arrange Brand brand = brandRepository.save(new Brand("Nike", "Just Do It.")); - productRepository.save(new Product(brand.getId(), "Air Max", "์„ค๋ช…1", 150000, 10)); - productRepository.save(new Product(brand.getId(), "Air Force", "์„ค๋ช…2", 130000, 10)); - productRepository.save(new Product(brand.getId(), "Jordan", "์„ค๋ช…3", 200000, 10)); + productRepository.save(new Product(brand.getId(), "Air Max", "์„ค๋ช…1", new Money(150000L), 10)); + productRepository.save(new Product(brand.getId(), "Air Force", "์„ค๋ช…2", new Money(130000L), 10)); + productRepository.save(new Product(brand.getId(), "Jordan", "์„ค๋ช…3", new Money(200000L), 10)); Pageable pageable = PageRequest.of(0, 2, Sort.by(Sort.Direction.DESC, "createdAt")); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index b731026ca..47f6e22c4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; @@ -51,7 +52,7 @@ class LikeProduct { void like_success_whenFirstLike() { // arrange User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act LikeInfo result = likeFacade.like(user.getId(), product.getId()); @@ -72,7 +73,7 @@ void like_success_whenSecondLike() { // arrange User user1 = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); User user2 = userRepository.save(new User("userB", "b@email.com", "2025-11-11", Gender.FEMALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); likeFacade.like(user1.getId(), product.getId()); @@ -95,7 +96,7 @@ void like_success_whenSecondLike() { void like_idempotent_whenDuplicateRequest() { // arrange User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); likeFacade.like(user.getId(), product.getId()); assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); @@ -121,7 +122,7 @@ class UnlikeProduct { void unlike_success_and_returns_updated_count() { // arrange User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); likeFacade.like(user.getId(), product.getId()); assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java index b27bd27a9..acbe3f667 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java @@ -5,6 +5,7 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.utils.DatabaseCleanUp; @@ -46,10 +47,10 @@ void return_productInfoPage_withBrandNames() { Brand brandA = brandRepository.save(new Brand("BrandA", "๋ธŒ๋žœ๋“œA")); Brand brandB = brandRepository.save(new Brand("BrandB", "๋ธŒ๋žœ๋“œB")); - productRepository.save(new Product(brandA.getId(), "Product A", "์„ค๋ช…", 20000, 10)); - productRepository.save(new Product(brandA.getId(), "Product B", "์„ค๋ช…", 15000, 10)); - productRepository.save(new Product(brandB.getId(), "Product C", "์„ค๋ช…", 10000, 10)); - productRepository.save(new Product(brandB.getId(), "Product D", "์„ค๋ช…", 30000, 10)); + productRepository.save(new Product(brandA.getId(), "Product A", "์„ค๋ช…", new Money(20000L), 10)); + productRepository.save(new Product(brandA.getId(), "Product B", "์„ค๋ช…", new Money(15000L), 10)); + productRepository.save(new Product(brandB.getId(), "Product C", "์„ค๋ช…", new Money(10000L), 10)); + productRepository.save(new Product(brandB.getId(), "Product D", "์„ค๋ช…", new Money(30000L), 10)); Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index 3fd330bf2..1d0d2aaa1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; @@ -52,7 +53,7 @@ class Create { void like_success_whenValidRequest() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act Like like = likeService.Like(user.getId(), product.getId()); @@ -70,7 +71,7 @@ void like_success_whenValidRequest() { void createLike_returnsExisting_whenAlreadyLiked() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act Like firstLike = likeService.Like(user.getId(), product.getId()); @@ -92,7 +93,7 @@ class UnlikeProduct { void deleteLike_success_whenPreviouslyLiked() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); likeRepository.save(new Like(user.getId(), product.getId())); // act @@ -107,7 +108,7 @@ void deleteLike_success_whenPreviouslyLiked() { void unlike_doNothing_whenNotLiked() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", 10000, 100)); + Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act & assert assertDoesNotThrow(() -> likeService.unLike(user.getId(), product.getId())); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java index 279a5af50..cf229d7b0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; @@ -55,8 +56,8 @@ class Create { void createOrder_whenValidProductIds_thenOrderIsCreatedAndSaved() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); - Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); + Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", new Money(30000L), 5)); + Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", new Money(50000L), 5)); List orderItems = List.of( new OrderItem(productA, 2), @@ -89,16 +90,16 @@ class GetList { void return_orderList_whenUserHasOrders() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); - Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); + Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", new Money(30000L), 5)); + Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", new Money(50000L), 5)); List orderItems = List.of( new OrderItem(productA, 2), new OrderItem(productB, 1) ); - Product productC = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…3", "์„ค๋ช…3", 20000, 10)); - Product productD = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…4", "์„ค๋ช…4", 10000, 8)); + Product productC = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…3", "์„ค๋ช…3", new Money(20000L), 10)); + Product productD = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…4", "์„ค๋ช…4", new Money(10000L), 8)); List orderItems2 = List.of( new OrderItem(productC, 1), @@ -146,8 +147,8 @@ class Get { void return_orderInfo_whenUserHasOrders() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", 30000, 5)); - Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", 50000, 5)); + Product productA = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…1", "์„ค๋ช…1", new Money(30000L), 5)); + Product productB = productRepository.save(new Product(1L, "์ƒํ’ˆ๋ช…2", "์„ค๋ช…2", new Money(50000L), 5)); List orderItems = List.of( new OrderItem(productA, 2), diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index 13f83a5f7..f1da4dc38 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import java.util.List; @@ -16,8 +17,8 @@ class OrderTest { @DisplayName("์œ ํšจํ•œ ์ƒํ’ˆ๊ณผ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•˜๋ฉด ์ •์ƒ์ ์œผ๋กœ ์ƒ์„ฑ๋œ๋‹ค.") void createOrder_success() { // arrange - Product product1 = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 30000, 15); - Product product2 = new Product(2L, "ํŒฌ์ธ ", "์„ค๋ช…", 50000, 16); + Product product1 = new Product(1L, "์…”์ธ ", "์„ค๋ช…", new Money(30000L), 15); + Product product2 = new Product(2L, "ํŒฌ์ธ ", "์„ค๋ช…", new Money(50000L), 16); OrderItem item1 = new OrderItem(product1, 2); OrderItem item2 = new OrderItem(product2, 1); @@ -42,7 +43,7 @@ void createOrder_fail_dueToEmptyItems() { @Test @DisplayName("userId๊ฐ€ null์ด๋ฉด ์ฃผ๋ฌธ ์ƒ์„ฑ์— ์‹คํŒจํ•œ๋‹ค.") void createOrder_fail_dueToNullUserId() { - Product product = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 30000, 10); + Product product = new Product(1L, "์…”์ธ ", "์„ค๋ช…", new Money(30000L), 10); OrderItem item = new OrderItem(product, 1); assertThatThrownBy(() -> new Order(null, List.of(item))) @@ -54,7 +55,7 @@ void createOrder_fail_dueToNullUserId() { @DisplayName("์ƒํ’ˆ ์žฌ๊ณ ๋ณด๋‹ค ๋งŽ์€ ์ˆ˜๋Ÿ‰์œผ๋กœ ์ฃผ๋ฌธ ์•„์ดํ…œ ์ƒ์„ฑ ์‹œ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") void createOrderItem_fail_dueToStock() { // given - Product product = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 30000, 2); + Product product = new Product(1L, "์…”์ธ ", "์„ค๋ช…", new Money(30000L), 2); // when & then assertThatThrownBy(() -> new OrderItem(product, 3)) @@ -66,8 +67,8 @@ void createOrderItem_fail_dueToStock() { @DisplayName("์ฃผ๋ฌธ ๊ธˆ์•ก์ด ์ •ํ™•ํžˆ ๊ณ„์‚ฐ๋œ๋‹ค.") void calculateTotalAmount_success() { // given - Product productA = new Product(1L, "์…”์ธ ", "์„ค๋ช…", 10000, 10); - Product productB = new Product(2L, "์ฒญ๋ฐ”์ง€", "์„ค๋ช…", 20000, 10); + Product productA = new Product(1L, "์…”์ธ ", "์„ค๋ช…", new Money(10000L), 10); + Product productB = new Product(2L, "์ฒญ๋ฐ”์ง€", "์„ค๋ช…", new Money(20000L), 10); OrderItem itemA = new OrderItem(productA, 3); // 3๋งŒ OrderItem itemB = new OrderItem(productB, 2); // 4๋งŒ diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java index e827ec9dd..b523dab95 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import com.loopers.domain.money.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -52,7 +53,7 @@ void return_productList_whenValidIdIsProvided() { 1l, "์ƒํ’ˆ๋ช…" + i, "์„ค๋ช…" + i, - 1000 * i, + new Money((long) (1000 * i)), 10 * i ); productRepository.save(product); @@ -95,7 +96,7 @@ void return_productList_sortedByPriceAsc() { 1L, "์ƒํ’ˆ๋ช…" + i, "์„ค๋ช…" + i, - 1000 * i, + new Money((long) (1000 * i)), 10 * i ); productRepository.save(product); @@ -141,7 +142,7 @@ class Get { void return_productInfo_whenProductExists() { // arrange Product savedProduct = productRepository.save(new Product( - 1L, "์ƒํ’ˆ๋ช…", "์„ค๋ช…", 50000, 5 + 1L, "์ƒํ’ˆ๋ช…", "์„ค๋ช…", new Money(50000L), 5 )); // act diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 81a185020..7a6ef904a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.loopers.domain.money.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -21,7 +22,7 @@ class Create { void create_product_with_valid_data() { // assert assertDoesNotThrow(() -> { - new Product(1L, "name", "description", 1000, 100); + new Product(1L, "name", "description", new Money(1000L), 100); }); } @@ -34,7 +35,7 @@ class ProductValidation { void shouldThrowException_whenBrandIdIsNull() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(null, "name", "description", 1000, 100); + new Product(null, "name", "description", new Money(1000L), 100); }); // assert @@ -45,7 +46,7 @@ void shouldThrowException_whenBrandIdIsNull() { void shouldThrowException_whenNameIsBlank() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(1L, "", "description", 1000, 100); + new Product(1L, "", "description", new Money(1000L), 100); }); // assert @@ -57,7 +58,7 @@ void shouldThrowException_whenNameIsBlank() { void shouldThrowException_whenDescriptionIsBlank() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(1L, "name", "", 1000, 100); + new Product(1L, "name", "", new Money(1000L), 100); }); // assert @@ -69,7 +70,7 @@ void shouldThrowException_whenDescriptionIsBlank() { void shouldThrowException_whenPriceIsNegative() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(1L, "name", "description", -1, 100); + new Product(1L, "name", "description", new Money(-1L), 100); }); // assert @@ -81,7 +82,7 @@ void shouldThrowException_whenPriceIsNegative() { void shouldThrowException_whenStockIsNegative() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(1L, "name", "", 1000, -1); + new Product(1L, "name", "", new Money(1000L), -1); }); // assert From 84c9c0d458f9ed003462e5fa13a6e37ecab2f559 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 18 Nov 2025 13:54:57 +0900 Subject: [PATCH 087/164] =?UTF-8?q?refactor:=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=93=A4=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/like/LikeFacade.java | 2 +- .../src/main/java/com/loopers/domain/like/LikeService.java | 2 +- .../main/java/com/loopers/interfaces/order/OrderV1Dto.java | 6 ++++-- .../loopers/application/like/LikeFacadeIntegrationTest.java | 2 +- .../com/loopers/domain/like/LikeServiceIntegrationTest.java | 6 +++--- .../test/java/com/loopers/domain/product/ProductTest.java | 4 ++-- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 04540b5a5..c3f77c97a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -16,7 +16,7 @@ public class LikeFacade { private final LikeService likeService; public LikeInfo like(long userId, long productId) { - Like like = likeService.Like(userId, productId); + Like like = likeService.like(userId, productId); long totalLikes = likeService.countLikesByProductId(productId); return LikeInfo.from(like, totalLikes); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index e5bc6d016..1a9b62da4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -9,7 +9,7 @@ public class LikeService { private final LikeRepository likeRepository; - public Like Like(long userId, long productId) { + public Like like(long userId, long productId) { return likeRepository.findByUserIdAndProductId(userId, productId) .orElseGet(() -> likeRepository.save(new Like(userId, productId))); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java index 74a9c660d..9f1b3cd75 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.order; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import java.util.List; public class OrderV1Dto { @@ -8,8 +10,8 @@ public record OrderRequest(List items) { } - public record OrderItemRequest(Long productId, - int quantity + public record OrderItemRequest(@NotNull Long productId, + @Positive int quantity ) { } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index 47f6e22c4..94f00702a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -67,7 +67,7 @@ void like_success_whenFirstLike() { ); } - @DisplayName("๋‘ ๋ฒˆ์งธ ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„์š” ์š”์ฒญ ์‹œ, ๊ฐฑ์‹  ์ „ ์นด์šดํŠธ(1)๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @DisplayName("๋‘ ๋ฒˆ์งธ ์‚ฌ์šฉ์ž๊ฐ€ ์ข‹์•„์š” ์š”์ฒญ ์‹œ, ๊ฐฑ์‹  ์ „ ์นด์šดํŠธ(2)๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test void like_success_whenSecondLike() { // arrange diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index 1d0d2aaa1..e38937037 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -56,7 +56,7 @@ void like_success_whenValidRequest() { Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act - Like like = likeService.Like(user.getId(), product.getId()); + Like like = likeService.like(user.getId(), product.getId()); // assert assertAll( @@ -74,8 +74,8 @@ void createLike_returnsExisting_whenAlreadyLiked() { Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act - Like firstLike = likeService.Like(user.getId(), product.getId()); - Like secondLike = likeService.Like(user.getId(), product.getId()); + Like firstLike = likeService.like(user.getId(), product.getId()); + Like secondLike = likeService.like(user.getId(), product.getId()); // assert assertAll( diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 7a6ef904a..172a9bcbc 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -42,7 +42,7 @@ void shouldThrowException_whenBrandIdIsNull() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @Test - @DisplayName("์ œํ’ˆ ์ด๋ฆ„(description)์ด ์—†๊ฑฐ๋‚˜(null) ๊ณต๋ฐฑ์ด๋ฉด, Product ๊ฐ์ฒด ์ƒ์„ฑ์€ ์‹คํŒจํ•œ๋‹ค.") + @DisplayName("์ œํ’ˆ ์ด๋ฆ„(name)์ด ์—†๊ฑฐ๋‚˜(null) ๊ณต๋ฐฑ์ด๋ฉด, Product ๊ฐ์ฒด ์ƒ์„ฑ์€ ์‹คํŒจํ•œ๋‹ค.") void shouldThrowException_whenNameIsBlank() { // act CoreException result = assertThrows(CoreException.class, () -> { @@ -82,7 +82,7 @@ void shouldThrowException_whenPriceIsNegative() { void shouldThrowException_whenStockIsNegative() { // act CoreException result = assertThrows(CoreException.class, () -> { - new Product(1L, "name", "", new Money(1000L), -1); + new Product(1L, "name", "description", new Money(1000L), -1); }); // assert From b9a414794fdebc1fa375bad645bb8810581e53ee Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 18 Nov 2025 14:12:47 +0900 Subject: [PATCH 088/164] =?UTF-8?q?refactor:=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20Lombok=EC=9C=BC=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 --- .../src/main/java/com/loopers/domain/brand/Brand.java | 6 +++--- .../src/main/java/com/loopers/domain/like/Like.java | 5 +++-- .../src/main/java/com/loopers/domain/order/Order.java | 6 +++--- .../src/main/java/com/loopers/domain/point/Point.java | 6 +++--- .../src/main/java/com/loopers/domain/product/Product.java | 6 +++--- .../src/main/java/com/loopers/domain/user/User.java | 6 +++--- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index be0b572ce..aa8015ded 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -6,9 +6,12 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; @Entity @Table(name = "brand") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Brand extends BaseEntity { @Column(nullable = false) @@ -17,9 +20,6 @@ public class Brand extends BaseEntity { @Column(nullable = false) private String description; - protected Brand() { - } - public Brand(String name, String description) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index f79908ce3..85049699a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -7,6 +7,8 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; @Entity @Table(name = "likes", uniqueConstraints = { @@ -15,6 +17,7 @@ columnNames = {"userId", "productId"} ) }) +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Like extends BaseEntity { @Column(nullable = false) private Long userId; @@ -22,8 +25,6 @@ public class Like extends BaseEntity { @Column(nullable = false) private Long productId; - protected Like() {} - public Like(Long userId, Long productId) { super(); if (userId == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index bae292fbc..26e209c56 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -11,9 +11,12 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; @Entity @Table(name = "orders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Order extends BaseEntity { @Column(nullable = false) @@ -23,9 +26,6 @@ public class Order extends BaseEntity { @JoinColumn(name = "order_id") private List orderItems = new java.util.ArrayList<>(); - protected Order() { - } - public Order(Long userId, List orderItems) { if (userId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 5f0bca00d..2f3b9a253 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -3,17 +3,17 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Embeddable; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; @Embeddable @EqualsAndHashCode +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Point { private int amount; - protected Point() { - } - public Point(int amount) { validate(amount); this.amount = amount; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index dc6d607a6..70549edde 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -8,9 +8,12 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; @Entity @Table(name = "product") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Product extends BaseEntity { @Column(nullable = false) @@ -29,9 +32,6 @@ public class Product extends BaseEntity { @Column() private int stock; - protected Product() { - } - public Product(Long brandId, String name, String description, Money price, int stock) { if (brandId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 9e9d22467..7cee1faa8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -9,9 +9,12 @@ import jakarta.persistence.Table; import java.time.LocalDate; import java.time.format.DateTimeParseException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; @Entity @Table(name = "user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseEntity { private static final String ID_PATTERN = "^[a-zA-Z0-9]{1,10}$"; @@ -29,9 +32,6 @@ public enum Gender { MALE, FEMALE } - protected User() { - } - public User(String userId, String email, String birthdate, Gender gender) { if (userId == null || !userId.matches(ID_PATTERN)) { From d52739a3ef54fa0da75ca2eb96a3c36dc3529956 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 18 Nov 2025 18:01:55 +0900 Subject: [PATCH 089/164] =?UTF-8?q?refactor:=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20Lombok=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20db=20=EC=BB=AC=EB=9F=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/order/Order.java | 11 +++++++++-- .../java/com/loopers/domain/order/OrderItem.java | 15 ++++++++++++--- .../java/com/loopers/domain/product/Product.java | 4 +++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 26e209c56..57bad4ee5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -1,11 +1,14 @@ package com.loopers.domain.order; import com.loopers.domain.BaseEntity; +import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; @@ -19,11 +22,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Order extends BaseEntity { - @Column(nullable = false) + @Column(name = "ref_user_id", nullable = false) private Long userId; + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_amount")) + private Money totalAmount; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) - @JoinColumn(name = "order_id") + @JoinColumn(name = "order_id", nullable = false) private List orderItems = new java.util.ArrayList<>(); public Order(Long userId, List orderItems) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 730de0795..a67b9005d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -3,19 +3,28 @@ import com.loopers.domain.BaseEntity; import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; @Entity @Table(name = "order_item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class OrderItem extends BaseEntity { + @Column(name = "ref_product_id", nullable = false) private Long productId; + + private Integer quantity; - private Money price; - protected OrderItem() { - } + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "price", nullable = false)) + private Money price; public OrderItem(Product product, int quantity) { if (product.getStock() < quantity) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 70549edde..c3bb4795c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -29,9 +29,11 @@ public class Product extends BaseEntity { @Embedded private Money price; - @Column() private int stock; + private int like_count; + + public Product(Long brandId, String name, String description, Money price, int stock) { if (brandId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๋ธŒ๋žœ๋“œ๋ฅผ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); From efc9a5fd226eca4078aa654f3e90c6668bb5d884 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 18 Nov 2025 19:32:49 +0900 Subject: [PATCH 090/164] =?UTF-8?q?refactor:=20=EC=83=81=ED=92=88=EC=9D=98?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=98=EB=A5=BC=20product?= =?UTF-8?q?=EC=9D=98=20=EC=BB=AC=EB=9F=BC=EC=97=90=20=EB=84=A3=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EB=A1=9C=EC=A7=81=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 --- .../loopers/application/like/LikeFacade.java | 21 ++++++++++---- .../application/product/ProductInfo.java | 9 ++++-- .../com/loopers/domain/like/LikeService.java | 14 ++++----- .../com/loopers/domain/product/Product.java | 28 ++++++++++++++++-- .../domain/product/ProductService.java | 9 ++++++ .../like/LikeFacadeIntegrationTest.java | 4 +-- .../like/LikeServiceIntegrationTest.java | 29 +++++-------------- 7 files changed, 72 insertions(+), 42 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index c3f77c97a..945ab3526 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -2,8 +2,10 @@ import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.UserService; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -16,13 +18,22 @@ public class LikeFacade { private final LikeService likeService; public LikeInfo like(long userId, long productId) { - Like like = likeService.like(userId, productId); - long totalLikes = likeService.countLikesByProductId(productId); - return LikeInfo.from(like, totalLikes); + Optional existingLike = likeService.findLike(userId, productId); + Product product = productService.getProduct(productId); + + if (existingLike.isPresent()) { + return LikeInfo.from(existingLike.get(), product.getLikeCount()); + } + + Like newLike = likeService.save(userId, productId); + int updatedLikeCount = productService.increaseLikeCount(product); + + return LikeInfo.from(newLike, updatedLikeCount); } - public long unLike(long userId, long productId) { + public int unLike(long userId, long productId) { likeService.unLike(userId, productId); - return likeService.countLikesByProductId(productId); + Product product = productService.getProduct(productId); + return productService.decreaseLikeCount(product); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index 26f278214..d5a6e82b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -7,14 +7,16 @@ public record ProductInfo( Long id, String name, Money price, - String brandName + String brandName, + int likeCount ) { public static ProductInfo from(Product product) { return new ProductInfo( product.getId(), product.getName(), product.getPrice(), - null + null, + product.getLikeCount() ); } @@ -23,7 +25,8 @@ public static ProductInfo from(Product product, String brandName) { product.getId(), product.getName(), product.getPrice(), - brandName + brandName, + product.getLikeCount() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 1a9b62da4..4a7de11df 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -9,16 +10,15 @@ public class LikeService { private final LikeRepository likeRepository; - public Like like(long userId, long productId) { - return likeRepository.findByUserIdAndProductId(userId, productId) - .orElseGet(() -> likeRepository.save(new Like(userId, productId))); + public Like save(long userId, long productId) { + return likeRepository.save(new Like(userId, productId)); } - public void unLike(Long userId, Long productId) { - likeRepository.deleteByUserIdAndProductId(userId, productId); + public Optional findLike(long userId, long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId); } - public long countLikesByProductId(Long productId) { - return likeRepository.countByProductId(productId); + public void unLike(Long userId, Long productId) { + likeRepository.deleteByUserIdAndProductId(userId, productId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index c3bb4795c..6457edde1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -31,7 +31,7 @@ public class Product extends BaseEntity { private int stock; - private int like_count; + private int likeCount; public Product(Long brandId, String name, String description, Money price, int stock) { @@ -47,6 +47,9 @@ public Product(Long brandId, String name, String description, Money price, int s throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์„ค๋ช…์„ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ๊ฐ€๊ฒฉ์„ ๋“ฑ๋กํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } if (stock < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ์˜ ์žฌ๊ณ ๋Š” ์Œ์ˆ˜๊ฐ€ ๋  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } @@ -78,7 +81,26 @@ public int getStock() { return stock; } - public void deductStock(int requestedQty) { - this.stock -= requestedQty; + public int getLikeCount() { + return likeCount; + } + + public void deductStock(int quantity) { + this.stock -= quantity; + } + + public int increaseLikeCount() { + this.likeCount++; + return this.likeCount; } + + public int decreaseLikeCount() { + if (this.likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š”์ˆ˜๋Š” 0๋ณด๋‹ค ์ž‘์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + this.likeCount--; + return this.likeCount; + + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index a36747201..dd43a2a5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -52,4 +53,12 @@ public void deductStock(List products, List orderItem } } + @Transactional + public int increaseLikeCount(Product product) { + return product.increaseLikeCount(); + } + + public int decreaseLikeCount(Product product) { + return product.decreaseLikeCount(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index 94f00702a..7dc519192 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -102,7 +102,7 @@ void like_idempotent_whenDuplicateRequest() { assertThat(likeRepository.countByProductId(product.getId())).isEqualTo(1); // act - LikeInfo result = likeFacade.like(user.getId(), product.getId()); // ์ค‘๋ณต ํ˜ธ์ถœ + LikeInfo result = likeFacade.like(user.getId(), product.getId()); // assert assertAll( @@ -132,10 +132,8 @@ void unlike_success_and_returns_updated_count() { // assert assertAll( - // 3. ๋ฐ˜ํ™˜๋œ ์นด์šดํŠธ๊ฐ€ 0์ธ์ง€ ํ™•์ธ () -> assertThat(totalLikes).isZero(), - // 4. ์‹ค์ œ DB์—์„œ๋„ ์‚ญ์ œ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ () -> assertThat(likeRepository.countByProductId(product.getId())).isZero() ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java index e38937037..4e43e15c4 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -11,6 +11,7 @@ import com.loopers.domain.user.User.Gender; import com.loopers.domain.user.UserRepository; import com.loopers.utils.DatabaseCleanUp; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -53,10 +54,11 @@ class Create { void like_success_whenValidRequest() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); + Product product = productRepository + .save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act - Like like = likeService.like(user.getId(), product.getId()); + Like like = likeService.save(user.getId(), product.getId()); // assert assertAll( @@ -65,23 +67,6 @@ void like_success_whenValidRequest() { () -> assertThat(like.getProductId()).isEqualTo(product.getId()) ); } - - @DisplayName("์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์— ๋‹ค์‹œ ์š”์ฒญํ•˜๋ฉด, ์ค‘๋ณต ์ €์žฅ๋˜์ง€ ์•Š๊ณ  ๊ธฐ์กด ์ข‹์•„์š” ์ •๋ณด๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค.- ๋ฉฑ๋“ฑ์„ฑ") - @Test - void createLike_returnsExisting_whenAlreadyLiked() { - // arrange - User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); - - // act - Like firstLike = likeService.like(user.getId(), product.getId()); - Like secondLike = likeService.like(user.getId(), product.getId()); - - // assert - assertAll( - () -> assertThat(secondLike.getId()).isEqualTo(firstLike.getId()) - ); - } } @DisplayName("์ƒํ’ˆ ์ข‹์•„์š” ์ทจ์†Œ ํ•  ๋•Œ,") @@ -93,7 +78,8 @@ class UnlikeProduct { void deleteLike_success_whenPreviouslyLiked() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); + Product product = productRepository + .save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); likeRepository.save(new Like(user.getId(), product.getId())); // act @@ -108,7 +94,8 @@ void deleteLike_success_whenPreviouslyLiked() { void unlike_doNothing_whenNotLiked() { // arrange User user = userRepository.save(new User("userId", "a@email.com", "2025-11-11", Gender.MALE)); - Product product = productRepository.save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); + Product product = productRepository + .save(new Product(1L, "์ƒํ’ˆA", "์„ค๋ช…", new Money(10000L), 100)); // act & assert assertDoesNotThrow(() -> likeService.unLike(user.getId(), product.getId())); From c9dcf7d2537b915e45841efa245b950b22423de5 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 18 Nov 2025 20:33:12 +0900 Subject: [PATCH 091/164] =?UTF-8?q?refactor:=20=EC=A3=BC=EB=AC=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B0=A8=EA=B0=90=20=EB=A1=9C=EC=A7=81=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=B0=94=EA=B9=A5?= =?UTF-8?q?=EC=AA=BD=20dto=20=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/order/OrderFacade.java | 10 ++++------ .../com/loopers/application/order/OrderInfo.java | 2 +- .../main/java/com/loopers/domain/order/Order.java | 5 +++++ .../main/java/com/loopers/domain/point/Point.java | 12 ++++++------ .../java/com/loopers/domain/point/PointService.java | 7 ++++--- .../com/loopers/domain/product/ProductService.java | 13 +++++++------ .../loopers/application/order/OrderFacadeTest.java | 7 +++++++ 7 files changed, 34 insertions(+), 22 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 08a8a08c4..9908fd56d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -41,15 +41,13 @@ public OrderInfo placeOrder(String userId, OrderRequest request) { .toList(); List products = productService.getProducts(productIds); - long totalAmount = orderService.calculateTotal(products, request.items()); - - - productService.deductStock(products, request.items()); - - pointService.deductPoint(user.getId(), totalAmount); List orderItems = buildOrderItems(products, request.items()); Order order = orderService.createOrder(user.getId(), orderItems); + long totalAmount = order.getTotalAmount().getValue(); + + productService.deductStock(products, orderItems); + pointService.deductPoint(user, totalAmount); return OrderInfo.from(order); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 52d951deb..67f33d2f4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -17,7 +17,7 @@ public static OrderInfo from(Order order) { order.getId(), order.getUserId(), order.getOrderItems().stream().map(OrderItemInfo::from).toList(), - order.calculateTotalAmount() + order.getTotalAmount().getValue() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 57bad4ee5..8cde47ca9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -43,6 +43,7 @@ public Order(Long userId, List orderItems) { } this.userId = userId; this.orderItems = orderItems; + this.totalAmount = new Money(calculateTotalAmount()); } public Long getUserId() { @@ -71,4 +72,8 @@ public long calculateTotalAmount() { .mapToLong(OrderItem::calculateAmount) .sum(); } + + public Money getTotalAmount() { + return totalAmount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java index 2f3b9a253..d61bb3a9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -12,30 +12,30 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Point { - private int amount; + private long amount; - public Point(int amount) { + public Point(long amount) { validate(amount); this.amount = amount; } - public Point add(int value) { + public Point add(long value) { if(value <= 0) throw new CoreException(ErrorType.BAD_REQUEST, "์ถฉ์ „ ๊ธˆ์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); return new Point(this.amount + value); } - public Point subtract(int value) { + public Point subtract(long value) { if (this.amount < value) { throw new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ ์ž”์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); } return new Point(this.amount - value); } - private void validate(int amount) { + private void validate(long amount) { if (amount < 0) throw new CoreException(ErrorType.BAD_REQUEST); } - public int getAmount() { + public long getAmount() { return amount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 7f6ccbd6c..434076f11 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -30,11 +30,12 @@ public PointResponse charge(String userId, int amount) { return PointResponse.from(chargePoint); } - public void deductPoint(Long userId, long totalAmount) { - long currentPoint = userRepository.findPointById(userId); + public void deductPoint(User user, long totalAmount) { - if (currentPoint < totalAmount) { + if (user.getPoint().getAmount() < totalAmount) { throw new CoreException(ErrorType.BAD_REQUEST, "์ž”์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); } + + user.getPoint().subtract(totalAmount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index dd43a2a5b..610764d13 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.domain.order.OrderItem; import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -39,17 +40,17 @@ public List getProducts(List productIds) { .toList(); } - public void deductStock(List products, List orderItems) { + public void deductStock(List products, List orderItems) { - Map qtyMap = orderItems.stream() - .collect(Collectors.toMap(OrderItemRequest::productId, OrderItemRequest::quantity)); + Map quantityMap = orderItems.stream() + .collect(Collectors.toMap(OrderItem::getProductId, OrderItem::getQuantity)); for (Product product : products) { - int requestedQty = qtyMap.get(product.getId()); - if (product.getStock() < requestedQty) { + int quantityToDeduct = quantityMap.get(product.getId()); + if (product.getStock() < quantityToDeduct) { throw new CoreException(ErrorType.BAD_REQUEST, "ํ’ˆ์ ˆ๋œ ์ƒํ’ˆ์ž…๋‹ˆ๋‹ค."); } - product.deductStock(requestedQty); + product.deductStock(quantityToDeduct); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..9dcb81bb1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,7 @@ +package com.loopers.application.order; + +import static org.junit.jupiter.api.Assertions.*; + +class OrderFacadeTest { + +} From 0978394cce6be0640d8f3edaf1c2cd7f2411cfad Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 19 Nov 2025 15:37:55 +0900 Subject: [PATCH 092/164] =?UTF-8?q?fix:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B0=A8=EA=B0=90=20=EC=9E=91=EB=8F=99=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/order/OrderItem.java | 4 +- .../loopers/domain/order/OrderService.java | 17 --- .../loopers/domain/point/PointService.java | 3 +- .../java/com/loopers/domain/user/User.java | 3 + .../order/OrderFacadeIntegrationTest.java | 127 ++++++++++++++++++ .../application/order/OrderFacadeTest.java | 7 - 6 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index a67b9005d..93ccd39ab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -3,6 +3,8 @@ import com.loopers.domain.BaseEntity; import com.loopers.domain.money.Money; import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -28,7 +30,7 @@ public class OrderItem extends BaseEntity { public OrderItem(Product product, int quantity) { if (product.getStock() < quantity) { - throw new IllegalArgumentException("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); + throw new CoreException(ErrorType.NOT_FOUND, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค"); } this.productId = product.getId(); this.quantity = quantity; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index ddc5f672e..99b129f30 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,12 +1,8 @@ package com.loopers.domain.order; -import com.loopers.domain.product.Product; -import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -29,17 +25,4 @@ public List getOrders(Long userId) { public Order getOrder(Long id) { return orderRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } - - public long calculateTotal(List products, List items) { - Map productMap = products.stream() - .collect(Collectors.toMap(Product::getId, p -> p)); - - long total = 0; - - for (OrderItemRequest item : items) { - Product p = productMap.get(item.productId()); - total += p.getPrice().getValue() * item.quantity(); - } - return total; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index 434076f11..57cbba8ba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -30,12 +30,13 @@ public PointResponse charge(String userId, int amount) { return PointResponse.from(chargePoint); } + @Transactional public void deductPoint(User user, long totalAmount) { if (user.getPoint().getAmount() < totalAmount) { throw new CoreException(ErrorType.BAD_REQUEST, "์ž”์•ก์ด ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); } - user.getPoint().subtract(totalAmount); + user.usePoint(totalAmount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 7cee1faa8..7b5cd005d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -91,4 +91,7 @@ public Point getPoint() { return point; } + public void usePoint(long amount) { + this.point = this.point.subtract(amount); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java new file mode 100644 index 000000000..14c9457de --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -0,0 +1,127 @@ +package com.loopers.application.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.domain.money.Money; +import com.loopers.domain.point.Point; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.User.Gender; +import com.loopers.domain.user.UserRepository; +import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; +import com.loopers.interfaces.order.OrderV1Dto.OrderRequest; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class OrderFacadeIntegrationTest { + + @Autowired + OrderFacade orderFacade; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ƒํ’ˆ ์ฃผ๋ฌธ์‹œ") + @Nested + class PlaceOrder { + @DisplayName("์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๋ฉด ์žฌ๊ณ ์™€ ํฌ์ธํŠธ๊ฐ€ ์ฐจ๊ฐ๋˜๊ณ  ์ฃผ๋ฌธ ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + @Test + void placeOrder_success() { + // arrange + User user = new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(100000L)); + userRepository.save(user); + + Long brandId = 1L; + Product product = new Product(brandId, "Product A", "์„ค๋ช…", new Money(20000L), 10); + Product saveProduct = productRepository.save(product); + + OrderItemRequest itemRequest = new OrderItemRequest(saveProduct.getId(), 3); + OrderRequest request = new OrderRequest(List.of(itemRequest)); + + // act + OrderInfo result = orderFacade.placeOrder(user.getUserId( + ), request); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.totalAmount()).isEqualTo(60000L), + () -> assertThat(result.items()).hasSize(1) + ); + + + Product updatedProduct = productRepository.findById(saveProduct.getId()).orElseThrow(); + assertThat(updatedProduct.getStock()).isEqualTo(7); + + User updatedUser = userRepository.findByUserId("userA").orElseThrow(); + assertThat(updatedUser.getPoint().getAmount()).isEqualTo(40000L); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์ฃผ๋ฌธ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void placeOrder_fail_insufficient_stock() { + // arrange + User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(100000L))); + + Product product = productRepository.save( + new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 2) + ); + + OrderItemRequest itemRequest = new OrderItemRequest(product.getId(), 3); + OrderRequest request = new OrderRequest(List.of(itemRequest)); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.placeOrder(user.getUserId(), request); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์ฃผ๋ฌธ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void placeOrder_fail_insufficient_point() { + // arrange + User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(10000L))); + + Product product = productRepository.save( + new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) + ); + + OrderItemRequest itemRequest = new OrderItemRequest(product.getId(), 1); + OrderRequest request = new OrderRequest(List.of(itemRequest)); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.placeOrder(user.getUserId(), request); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java deleted file mode 100644 index 9dcb81bb1..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.loopers.application.order; - -import static org.junit.jupiter.api.Assertions.*; - -class OrderFacadeTest { - -} From 4a7962092848d52d1f00f91bc70441e08bac2f23 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 20 Nov 2025 13:14:51 +0900 Subject: [PATCH 093/164] =?UTF-8?q?feature:=20order=20contoller=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=ED=8C=8C=EC=82=AC=EB=93=9C=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=EC=9D=84=20=EC=9C=84=ED=95=9C=20=20command=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 22 +++++----- .../loopers/domain/order/OrderCommand.java | 20 +++++++++ .../domain/product/ProductService.java | 1 - .../loopers/domain/user/UserRepository.java | 2 + .../com/loopers/domain/user/UserService.java | 7 ++++ .../user/UserRepositoryImpl.java | 5 +++ .../interfaces/api/order/OrderV1ApiSpec.java | 24 +++++++++++ .../api/order/OrderV1Controller.java | 31 ++++++++++++++ .../interfaces/api/order/OrderV1Dto.java | 41 +++++++++++++++++++ .../loopers/interfaces/order/OrderV1Dto.java | 18 -------- .../order/OrderFacadeIntegrationTest.java | 27 ++++++------ 11 files changed, 153 insertions(+), 45 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 9908fd56d..8cf4ba0fe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,6 +1,8 @@ package com.loopers.application.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderCommand.Item; +import com.loopers.domain.order.OrderCommand.PlaceOrder; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; import com.loopers.domain.point.PointService; @@ -8,8 +10,6 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; -import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; -import com.loopers.interfaces.order.OrderV1Dto.OrderRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; @@ -22,27 +22,25 @@ @RequiredArgsConstructor @Component public class OrderFacade { + private final ProductService productService; private final UserService userService; private final OrderService orderService; private final PointService pointService; @Transactional - public OrderInfo placeOrder(String userId, OrderRequest request) { - - if (userId == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } + public OrderInfo placeOrder(PlaceOrder command) { - User user = userService.getUser(userId); + User user = userService.findById(command.userId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - List productIds = request.items().stream() - .map(OrderItemRequest::productId) + List productIds = command.items().stream() + .map(Item::productId) .toList(); List products = productService.getProducts(productIds); - List orderItems = buildOrderItems(products, request.items()); + List orderItems = buildOrderItems(products, command.items()); Order order = orderService.createOrder(user.getId(), orderItems); long totalAmount = order.getTotalAmount().getValue(); @@ -52,7 +50,7 @@ public OrderInfo placeOrder(String userId, OrderRequest request) { return OrderInfo.from(order); } - private List buildOrderItems(List products, List items) { + private List buildOrderItems(List products, List items) { Map productMap = products.stream() .collect(Collectors.toMap(Product::getId, p -> p)); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java new file mode 100644 index 000000000..8ac362b47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java @@ -0,0 +1,20 @@ +package com.loopers.domain.order; + +import java.util.List; + +public class OrderCommand { + + public record PlaceOrder( + Long userId, + List items + ) { + + } + + public record Item( + Long productId, + int quantity + ) { + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 610764d13..c486b17ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,7 +1,6 @@ package com.loopers.domain.product; import com.loopers.domain.order.OrderItem; -import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 20aae68fa..b8bb21f07 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -8,4 +8,6 @@ public interface UserRepository { Optional findByUserId(String userId); long findPointById(Long userId); + + Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 27f5f57d5..b58879fca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -2,8 +2,10 @@ import com.loopers.domain.user.UserCommand.UserCreationCommand; import com.loopers.interfaces.api.user.UserV1Dto.UserResponse; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -23,4 +25,9 @@ public UserResponse signUp(UserCreationCommand command) { public User getUser(String userId) { return userRepository.findByUserId(userId).orElse(null); } + + @Transactional(readOnly = true) + public Optional findById(Long id) { + return userRepository.findById(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 2fcdfb105..fc31787c2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -25,4 +25,9 @@ public Optional findByUserId(String userId) { public long findPointById(Long userId) { return userJpaRepository.findPointById(userId); } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..b6d3abba4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Order V1 API", description = "์ฃผ๋ฌธ ๊ด€๋ จ API ์ž…๋‹ˆ๋‹ค.") +public interface OrderV1ApiSpec { + + @Operation( + summary = "์ฃผ๋ฌธ ์ƒ์„ฑ", + description = "์‚ฌ์šฉ์ž๊ฐ€ ์ƒํ’ˆ์„ ์ฃผ๋ฌธํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse createOrder( + @Schema(name = "์‚ฌ์šฉ์ž ID", description = "์ฃผ๋ฌธํ•˜๋Š” ์‚ฌ์šฉ์ž์˜ ID") + @RequestHeader("X-USER-ID") Long userId, + + @Schema(name = "์ฃผ๋ฌธ ์ƒ์„ฑ ์š”์ฒญ", description = "์ฃผ๋ฌธํ•  ์ƒํ’ˆ ์ •๋ณด") + @RequestBody OrderV1Dto.OrderRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..2f69ce9a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.order.OrderV1Dto.OrderRequest; +import com.loopers.interfaces.api.order.OrderV1Dto.OrderResponse; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + + @Override + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-USER-ID") Long userId, + @RequestBody OrderRequest request) { + OrderInfo orderInfo = orderFacade.placeOrder(request.toCommand(userId)); + return ApiResponse.success(OrderResponse.from(orderInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..59461c66d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,41 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderCommand; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.util.List; + +public class OrderV1Dto { + + public record OrderRequest(List items) { + + public OrderCommand.PlaceOrder toCommand(Long userId) { + List commandItems = items.stream() + .map(item -> new OrderCommand.Item(item.productId(), item.quantity())) + .toList(); + + return new OrderCommand.PlaceOrder(userId, commandItems); + } + } + + public record OrderItemRequest(@NotNull Long productId, + @Positive int quantity + ) { + + } + + public record OrderResponse(Long orderId, + Long userId, + long totalPrice + ) { + + public static OrderResponse from(OrderInfo response) { + return new OrderResponse( + response.orderId(), + response.userId(), + response.totalAmount() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java deleted file mode 100644 index 9f1b3cd75..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/order/OrderV1Dto.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.loopers.interfaces.order; - -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import java.util.List; - -public class OrderV1Dto { - - public record OrderRequest(List items) { - - } - - public record OrderItemRequest(@NotNull Long productId, - @Positive int quantity - ) { - - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 14c9457de..2e9a6b50f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -1,18 +1,18 @@ package com.loopers.application.order; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.loopers.domain.money.Money; +import com.loopers.domain.order.OrderCommand; +import com.loopers.domain.order.OrderCommand.Item; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; import com.loopers.domain.user.User.Gender; import com.loopers.domain.user.UserRepository; -import com.loopers.interfaces.order.OrderV1Dto.OrderItemRequest; -import com.loopers.interfaces.order.OrderV1Dto.OrderRequest; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -60,12 +60,11 @@ void placeOrder_success() { Product product = new Product(brandId, "Product A", "์„ค๋ช…", new Money(20000L), 10); Product saveProduct = productRepository.save(product); - OrderItemRequest itemRequest = new OrderItemRequest(saveProduct.getId(), 3); - OrderRequest request = new OrderRequest(List.of(itemRequest)); + Item itemCommand = new Item(saveProduct.getId(), 3); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); // act - OrderInfo result = orderFacade.placeOrder(user.getUserId( - ), request); + OrderInfo result = orderFacade.placeOrder(command); // assert assertAll( @@ -92,12 +91,12 @@ void placeOrder_fail_insufficient_stock() { new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 2) ); - OrderItemRequest itemRequest = new OrderItemRequest(product.getId(), 3); - OrderRequest request = new OrderRequest(List.of(itemRequest)); + Item itemCommand = new Item(product.getId(), 3); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.placeOrder(user.getUserId(), request); + orderFacade.placeOrder(command); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -113,12 +112,12 @@ void placeOrder_fail_insufficient_point() { new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) ); - OrderItemRequest itemRequest = new OrderItemRequest(product.getId(), 1); - OrderRequest request = new OrderRequest(List.of(itemRequest)); + Item itemCommand = new Item(product.getId(), 1); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.placeOrder(user.getUserId(), request); + orderFacade.placeOrder(command); }); assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); From 1afa04f6b9cf033d203e630db2e6fac536c1fada Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 20 Nov 2025 16:21:37 +0900 Subject: [PATCH 094/164] =?UTF-8?q?feature:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B0=A8=EA=B0=90=20=EC=8B=9C=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=EC=A0=9C=EC=96=B4=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=B9=84?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20-=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=EC=8B=9C=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B0=A8=EA=B0=90?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9C=EC=83=9D=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=B9=84=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=98?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 3 +- .../loopers/domain/user/UserRepository.java | 2 + .../com/loopers/domain/user/UserService.java | 4 + .../user/UserJpaRepository.java | 8 ++ .../user/UserRepositoryImpl.java | 5 + .../order/OrderConcurrencyTest.java | 100 ++++++++++++++++++ 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 8cf4ba0fe..62edaab5e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -21,6 +21,7 @@ @RequiredArgsConstructor @Component +@Transactional public class OrderFacade { private final ProductService productService; @@ -31,7 +32,7 @@ public class OrderFacade { @Transactional public OrderInfo placeOrder(PlaceOrder command) { - User user = userService.findById(command.userId()) + User user = userService.findByIdWithLock(command.userId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); List productIds = command.items().stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index b8bb21f07..61cbe38e4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -10,4 +10,6 @@ public interface UserRepository { long findPointById(Long userId); Optional findById(Long id); + + Optional findByUserIdWithLock(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index b58879fca..8bf63f768 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -30,4 +30,8 @@ public User getUser(String userId) { public Optional findById(Long id) { return userRepository.findById(id); } + + public Optional findByIdWithLock(Long id) { + return userRepository.findByUserIdWithLock(id); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index e92642bc9..ac55b6238 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,12 +1,20 @@ package com.loopers.infrastructure.user; import com.loopers.domain.user.User; +import jakarta.persistence.LockModeType; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface UserJpaRepository extends JpaRepository { Optional findByUserId(String userId); long findPointById(Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :id") + Optional findByUserIdWithLock(@Param("id") Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index fc31787c2..c623d83cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -30,4 +30,9 @@ public long findPointById(Long userId) { public Optional findById(Long id) { return userJpaRepository.findById(id); } + + @Override + public Optional findByUserIdWithLock(Long id) { + return userJpaRepository.findByUserIdWithLock(id); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java new file mode 100644 index 000000000..60e116024 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -0,0 +1,100 @@ +package com.loopers.application.order; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.loopers.domain.money.Money; +import com.loopers.domain.order.OrderCommand.Item; +import com.loopers.domain.order.OrderCommand.PlaceOrder; +import com.loopers.domain.point.Point; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +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.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class OrderConcurrencyTest { + + @Autowired + private OrderFacade orderFacade; + @Autowired + private UserRepository userRepository; + @Autowired + private UserJpaRepository userJpaRepository; + @Autowired + private ProductRepository productRepository; + @Autowired + private ProductJpaRepository productJpaRepository; + + private User savedUser; + private Product savedProduct; + + @BeforeEach + void setUp() { + User user = new User("testUserId", "test@email.com", "1990-01-01", User.Gender.MALE, + new Point(10000L)); + this.savedUser = userJpaRepository.saveAndFlush(user); + + Product product = new Product(1L, "ํ…Œ์ŠคํŠธ์ƒํ’ˆ", "์ƒํ’ˆ ์„ค๋ช…", new Money(1000L), 100); + this.savedProduct = productJpaRepository.saveAndFlush(product); + } + + @AfterEach + void tearDown() { + userJpaRepository.deleteAll(); + productJpaRepository.deleteAll(); + } + + @Test + @DisplayName("๋™์‹œ์— 10๋ช…์ด ์ฃผ๋ฌธ์„ ํ•ด๋„ ํฌ์ธํŠธ๋Š” ์ˆœ์ฐจ์ ์œผ๋กœ ์ •ํ™•ํžˆ ์ฐจ๊ฐ๋˜์–ด์•ผ ํ•œ๋‹ค.") + void concurrency_point_deduction_test() throws InterruptedException { + // given + int threadCount = 10; + + PlaceOrder command = new PlaceOrder(savedUser.getId(), List.of(new Item(savedProduct.getId(), 1))); + + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + orderFacade.placeOrder(command); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // then + User findUser = userRepository.findById(savedUser.getId()).orElseThrow(); + + long expectedPoint = 10000L - (1000L * threadCount); + + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(findUser.getPoint().getAmount()).isEqualTo(expectedPoint); + } +} From cafb370ec4de15074b2a906056c39a1747e0c07d Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 20 Nov 2025 17:26:08 +0900 Subject: [PATCH 095/164] =?UTF-8?q?feature:=20=EC=9E=AC=EA=B3=A0=20?= =?UTF-8?q?=EC=B0=A8=EA=B0=90=20=EC=8B=9C=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20-=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8=EA=B0=90=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EB=B0=9C=EC=83=9D=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=ED=95=98?= =?UTF-8?q?=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 4 + .../order/OrderConcurrencyTest.java | 171 ++++++++++++------ 2 files changed, 124 insertions(+), 51 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 6457edde1..690fde127 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -8,6 +8,7 @@ import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -33,6 +34,9 @@ public class Product extends BaseEntity { private int likeCount; + @Version + private Long version; + public Product(Long brandId, String name, String description, Money price, int stock) { if (brandId == null) { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index 60e116024..2fe6cf1b0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -7,7 +7,6 @@ import com.loopers.domain.order.OrderCommand.PlaceOrder; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; import com.loopers.infrastructure.product.ProductJpaRepository; @@ -17,84 +16,154 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -public class OrderConcurrencyTest { +class OrderConcurrencyTest { @Autowired private OrderFacade orderFacade; + @Autowired private UserRepository userRepository; + @Autowired private UserJpaRepository userJpaRepository; - @Autowired - private ProductRepository productRepository; + @Autowired private ProductJpaRepository productJpaRepository; - private User savedUser; - private Product savedProduct; - - @BeforeEach - void setUp() { - User user = new User("testUserId", "test@email.com", "1990-01-01", User.Gender.MALE, - new Point(10000L)); - this.savedUser = userJpaRepository.saveAndFlush(user); - - Product product = new Product(1L, "ํ…Œ์ŠคํŠธ์ƒํ’ˆ", "์ƒํ’ˆ ์„ค๋ช…", new Money(1000L), 100); - this.savedProduct = productJpaRepository.saveAndFlush(product); - } - @AfterEach void tearDown() { userJpaRepository.deleteAll(); productJpaRepository.deleteAll(); } - @Test - @DisplayName("๋™์‹œ์— 10๋ช…์ด ์ฃผ๋ฌธ์„ ํ•ด๋„ ํฌ์ธํŠธ๋Š” ์ˆœ์ฐจ์ ์œผ๋กœ ์ •ํ™•ํžˆ ์ฐจ๊ฐ๋˜์–ด์•ผ ํ•œ๋‹ค.") - void concurrency_point_deduction_test() throws InterruptedException { - // given - int threadCount = 10; - - PlaceOrder command = new PlaceOrder(savedUser.getId(), List.of(new Item(savedProduct.getId(), 1))); - - ExecutorService executorService = Executors.newFixedThreadPool(32); - CountDownLatch latch = new CountDownLatch(threadCount); - - AtomicInteger successCount = new AtomicInteger(); - AtomicInteger failCount = new AtomicInteger(); - - - // when - for (int i = 0; i < threadCount; i++) { - executorService.submit(() -> { - try { - orderFacade.placeOrder(command); - successCount.getAndIncrement(); - } catch (Exception e) { - System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); - } - }); + @Nested + @DisplayName("ํฌ์ธํŠธ ๋™์‹œ์„ฑ (๋น„๊ด€์  ๋ฝ)") + class PointConcurrency { + + private User savedUser; + private Product savedProduct; + + @BeforeEach + void setUp() { + User user = new User("testUser", "test@email.com", "1990-01-01", User.Gender.MALE, + new Point(10000L)); + this.savedUser = userJpaRepository.saveAndFlush(user); + + Product product = new Product(1l, "ํ…Œ์ŠคํŠธ์ƒํ’ˆ", "์ƒํ’ˆ ์„ค๋ช…", new Money(1000L), 100); + this.savedProduct = productJpaRepository.saveAndFlush(product); + } + + @Test + @DisplayName("๋™์‹œ์— 10๋ฒˆ ์ฃผ๋ฌธ ์‹œ, ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์ธํ•ด ํฌ์ธํŠธ๋Š” ์ˆœ์ฐจ์ ์œผ๋กœ ์ •ํ™•ํžˆ ์ฐจ๊ฐ๋˜์–ด์•ผ ํ•œ๋‹ค.") + void point_deduction_concurrency_test() throws InterruptedException { + // arrange + int threadCount = 10; + PlaceOrder command = new PlaceOrder(savedUser.getId(), + List.of(new Item(savedProduct.getId(), 1))); + + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // act + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + orderFacade.placeOrder(command); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // assert + User findUser = userRepository.findById(savedUser.getId()).orElseThrow(); + + long expectedPoint = 10000L - (1000L * threadCount); + + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(findUser.getPoint().getAmount()).isEqualTo(expectedPoint); } + } - latch.await(); + @Nested + @DisplayName("์žฌ๊ณ  ๋™์‹œ์„ฑ (๋‚™๊ด€์  ๋ฝ)") + class StockConcurrency { - // then - User findUser = userRepository.findById(savedUser.getId()).orElseThrow(); + private Product savedProduct; - long expectedPoint = 10000L - (1000L * threadCount); + @BeforeEach + void setUp() { + Product product = new Product(1l, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(1000L), 100); + this.savedProduct = productJpaRepository.saveAndFlush(product); + } - assertThat(successCount.get()).isEqualTo(threadCount); - assertThat(findUser.getPoint().getAmount()).isEqualTo(expectedPoint); + @Test + @DisplayName("์„œ๋กœ ๋‹ค๋ฅธ 10๋ช…์ด ๋™์‹œ์— ์ฃผ๋ฌธ ์‹œ, ๋‚™๊ด€์  ๋ฝ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ•ด๋„ ์žฌ๊ณ  ์ •ํ•ฉ์„ฑ์€ ์œ ์ง€๋˜์–ด์•ผ ํ•œ๋‹ค.") + void stock_deduction_concurrency_test() throws InterruptedException { + // arrange + int threadCount = 10; + + List multiUsers = IntStream.range(0, threadCount) + .mapToObj(i -> { + User user = new User("user" + i, "user" + i + "@test.com", "2000-01-01", + User.Gender.FEMALE, new Point(10000L)); + return userJpaRepository.saveAndFlush(user); + }) + .toList(); + + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // act + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + PlaceOrder command = new PlaceOrder(multiUsers.get(index).getId(), + List.of(new Item(savedProduct.getId(), 1))); + orderFacade.placeOrder(command); + + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ฃผ๋ฌธ ์‹คํŒจ(์ถฉ๋Œ ๊ฐ์ง€): " + e.getMessage()); + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + // assert + Product findProduct = productJpaRepository.findById(savedProduct.getId()).orElseThrow(); + + long expectedStock = savedProduct.getStock() - successCount.get(); + + System.out.println("์„ฑ๊ณต: " + successCount.get() + ", ์ถฉ๋Œ ์‹คํŒจ: " + failCount.get()); + assertThat(findProduct.getStock()).isEqualTo(expectedStock); + } } } From ae5a7c8caef547921f11d1e00ea49dcc66be662b Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 20 Nov 2025 18:11:50 +0900 Subject: [PATCH 096/164] =?UTF-8?q?test:=20=EB=A1=A4=EB=B0=B1=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=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 --- .../order/OrderFacadeIntegrationTest.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 2e9a6b50f..a16c4038a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -23,10 +23,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; @SpringBootTest -@Transactional class OrderFacadeIntegrationTest { @Autowired @@ -54,14 +52,14 @@ class PlaceOrder { void placeOrder_success() { // arrange User user = new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(100000L)); - userRepository.save(user); + User savedUser = userRepository.save(user); Long brandId = 1L; Product product = new Product(brandId, "Product A", "์„ค๋ช…", new Money(20000L), 10); Product saveProduct = productRepository.save(product); Item itemCommand = new Item(saveProduct.getId(), 3); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(savedUser.getId(), List.of(itemCommand)); // act OrderInfo result = orderFacade.placeOrder(command); @@ -122,5 +120,33 @@ void placeOrder_fail_insufficient_point() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } + + @DisplayName("ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ์œผ๋กœ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•˜๋ฉด, ์ฐจ๊ฐ๋˜์—ˆ๋˜ ์žฌ๊ณ ๋Š” ๋กค๋ฐฑ๋˜์–ด ์›์ƒ๋ณต๊ตฌ๋œ๋‹ค.") + @Test + void placeOrder_transaction_rollback_test() { + // arrange + Product product = productRepository.save( + new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) + ); + + User user = userRepository.save( + new User("userRollback", "rollback@email.com", "2025-11-11", Gender.MALE, new Point(0L)) + ); + + Item itemCommand = new Item(product.getId(), 1); // 1๊ฐœ ์ฃผ๋ฌธ ์‹œ๋„ + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + + // act + assertThrows(CoreException.class, () -> { + orderFacade.placeOrder(command); + }); + + // assert + Product rollbackedProduct = productRepository.findById(product.getId()).orElseThrow(); + + assertThat(rollbackedProduct.getStock()).isEqualTo(10); + } } + + } From eaf0fb2227afaddb20cffe97569d9d8026325620 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 20 Nov 2025 18:46:48 +0900 Subject: [PATCH 097/164] =?UTF-8?q?fix:=20=EB=8F=99=EC=9D=BC=ED=95=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EA=B0=80=20=EC=84=9C=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=A3=BC=EB=AC=B8=EC=9D=84=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=EC=97=90=20=EC=88=98=ED=96=89=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderConcurrencyTest.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index 2fe6cf1b0..d77cb4360 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -51,7 +51,7 @@ void tearDown() { class PointConcurrency { private User savedUser; - private Product savedProduct; + private List distinctProducts; @BeforeEach void setUp() { @@ -59,8 +59,12 @@ void setUp() { new Point(10000L)); this.savedUser = userJpaRepository.saveAndFlush(user); - Product product = new Product(1l, "ํ…Œ์ŠคํŠธ์ƒํ’ˆ", "์ƒํ’ˆ ์„ค๋ช…", new Money(1000L), 100); - this.savedProduct = productJpaRepository.saveAndFlush(product); + this.distinctProducts = IntStream.range(0, 10) + .mapToObj(i -> { + Product product = new Product(1l, "์ƒํ’ˆ" + i, "์„ค๋ช…", new Money(1000L), 100); + return productJpaRepository.saveAndFlush(product); + }) + .toList(); } @Test @@ -68,8 +72,6 @@ void setUp() { void point_deduction_concurrency_test() throws InterruptedException { // arrange int threadCount = 10; - PlaceOrder command = new PlaceOrder(savedUser.getId(), - List.of(new Item(savedProduct.getId(), 1))); ExecutorService executorService = Executors.newFixedThreadPool(32); CountDownLatch latch = new CountDownLatch(threadCount); @@ -79,8 +81,12 @@ void point_deduction_concurrency_test() throws InterruptedException { // act for (int i = 0; i < threadCount; i++) { + final int index = i; executorService.submit(() -> { try { + Product targetProduct = distinctProducts.get(index); + PlaceOrder command = new PlaceOrder(savedUser.getId(), + List.of(new Item(targetProduct.getId(), 1))); orderFacade.placeOrder(command); successCount.getAndIncrement(); } catch (Exception e) { From 08d6f36e21d3f5ec9a6a28d1d8bac140c029b1a4 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 20 Nov 2025 20:22:24 +0900 Subject: [PATCH 098/164] =?UTF-8?q?feature:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=EC=99=80=20=EC=8B=AB=EC=96=B4=EC=9A=94=20=EC=8B=9C=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=A0=9C=EC=96=B4=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 10 +- .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 5 + .../product/ProductJpaRepository.java | 8 + .../product/ProductRepositoryImpl.java | 5 + .../application/like/LikeConcurrencyTest.java | 167 ++++++++++++++++++ 6 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 945ab3526..14c913fb8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -8,32 +8,36 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component public class LikeFacade { - private final UserService userService; private final ProductService productService; private final LikeService likeService; + @Transactional public LikeInfo like(long userId, long productId) { Optional existingLike = likeService.findLike(userId, productId); - Product product = productService.getProduct(productId); + if (existingLike.isPresent()) { + Product product = productService.getProduct(productId); return LikeInfo.from(existingLike.get(), product.getLikeCount()); } + Product product = productService.getProductWithLock(productId); Like newLike = likeService.save(userId, productId); int updatedLikeCount = productService.increaseLikeCount(product); return LikeInfo.from(newLike, updatedLikeCount); } + @Transactional public int unLike(long userId, long productId) { likeService.unLike(userId, productId); - Product product = productService.getProduct(productId); + Product product = productService.getProductWithLock(productId); return productService.decreaseLikeCount(product); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 7bd29a48b..56a7d8362 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -13,4 +13,6 @@ public interface ProductRepository { Optional findById(Long id); Page findByBrandId(Long brandId, Pageable pageable); + + Optional findByIdWithLock(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index c486b17ee..06fc12891 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -61,4 +61,9 @@ public int increaseLikeCount(Product product) { public int decreaseLikeCount(Product product) { return product.decreaseLikeCount(); } + + public Product getProductWithLock(Long id) { + return productRepository.findByIdWithLock(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index ad952361a..b675030b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,11 +1,19 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; +import jakarta.persistence.LockModeType; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; public interface ProductJpaRepository extends JpaRepository { Page findByBrandId(Long brandId, Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from Product p where p.id = :id") + Optional findByIdWithLock(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index a1880faff..4275f9c11 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -33,4 +33,9 @@ public Optional findById(Long id) { public Page findByBrandId(Long brandId, Pageable pageable) { return productJpaRepository.findByBrandId(brandId, pageable); } + + @Override + public Optional findByIdWithLock(Long id) { + return productJpaRepository.findByIdWithLock(id); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java new file mode 100644 index 000000000..b3071c654 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java @@ -0,0 +1,167 @@ +package com.loopers.application.like; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.loopers.domain.money.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.user.User; +import com.loopers.infrastructure.user.UserJpaRepository; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class LikeConcurrencyTest { + @Autowired + private LikeFacade likeFacade; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private com.loopers.utils.DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("์ข‹์•„์š” ์ฆ๊ฐ€ ๋™์‹œ์„ฑ (๋น„๊ด€์  ๋ฝ)") + class LikeIncreaseConcurrency { + + private Product targetProduct; + private List users; + + @BeforeEach + void setUp() { + Product product = new Product(1l, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000L), 100); + this.targetProduct = productRepository.save(product); + + this.users = IntStream.range(0, 10) + .mapToObj(i -> userJpaRepository.save( + new User("user" + i, "user" + i + "@email.com", "2000-01-01", User.Gender.MALE) + )) + .toList(); + } + + @Test + @DisplayName("๋™์‹œ์— 10๋ช…์ด ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅด๋ฉด, ์ƒํ’ˆ์˜ ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” ์ •ํ™•ํžˆ 10๊ฐœ๊ฐ€ ๋˜์–ด์•ผ ํ•œ๋‹ค.") + void like_concurrency_test() throws InterruptedException { + // arrange + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // act + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + likeFacade.like(users.get(index).getId(), targetProduct.getId()); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ข‹์•„์š” ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow(); + + System.out.println("์ข‹์•„์š” ์„ฑ๊ณต: " + successCount.get() + ", ์‹คํŒจ: " + failCount.get()); + + // assert + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(resultProduct.getLikeCount()).isEqualTo(10); + } + } + + @Nested + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ๋™์‹œ์„ฑ (๋น„๊ด€์  ๋ฝ)") + class LikeDecreaseConcurrency { + + private Product targetProduct; + private List users; + + @BeforeEach + void setUp() { + Product product = new Product(1l, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000L), 100); + this.targetProduct = productRepository.save(product); + + this.users = IntStream.range(0, 10) + .mapToObj(i -> userJpaRepository.save( + new User("user" + i, "user" + i + "@email.com", "2000-01-01", User.Gender.MALE) + )) + .toList(); + + for (User user : users) { + likeFacade.like(user.getId(), targetProduct.getId()); + } + } + + @Test + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ 10๋ช…์ด ๋™์‹œ์— ์ทจ์†Œ๋ฅผ ์š”์ฒญํ•˜๋ฉด, ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ๊ฐ€ ๋˜์–ด์•ผ ํ•œ๋‹ค.") + void unlike_concurrency_test() throws InterruptedException { + // arrange + int threadCount = 10; + + Product initialProduct = productRepository.findById(targetProduct.getId()).orElseThrow(); + assertThat(initialProduct.getLikeCount()).isEqualTo(10); + + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(); + AtomicInteger failCount = new AtomicInteger(); + + // act + for (int i = 0; i < threadCount; i++) { + final int index = i; + executorService.submit(() -> { + try { + likeFacade.unLike(users.get(index).getId(), targetProduct.getId()); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ทจ์†Œ ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + + + Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow(); + + System.out.println("์ทจ์†Œ ์„ฑ๊ณต: " + successCount.get() + ", ์‹คํŒจ: " + failCount.get()); + + // assert + assertThat(successCount.get()).isEqualTo(threadCount); + assertThat(resultProduct.getLikeCount()).isZero(); + } + } +} From 982cca708b3dc9027949c4d25dc59a07aaeaa2e8 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 21 Nov 2025 09:41:05 +0900 Subject: [PATCH 099/164] =?UTF-8?q?refactor:=20=EB=B9=84=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EB=9D=BD=EC=97=90=EC=84=9C=20=EB=82=99=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20?= =?UTF-8?q?=EB=A9=98=ED=86=A0=EB=A7=81=20=ED=9B=84=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=EC=9D=B4=20=EC=9A=94=EA=B5=AC=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EA=B3=B3=EC=9D=B4=EB=9D=BC=20=EB=82=99?= =?UTF-8?q?=EA=B4=80=EC=A0=81=20=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 53 ++++++++--- .../com/loopers/domain/like/LikeService.java | 2 + .../domain/product/ProductRepository.java | 2 - .../domain/product/ProductService.java | 17 ++-- .../product/ProductJpaRepository.java | 8 -- .../product/ProductRepositoryImpl.java | 5 -- .../application/like/LikeConcurrencyTest.java | 89 ++++++++----------- .../order/OrderConcurrencyTest.java | 86 ++++++++---------- 8 files changed, 132 insertions(+), 130 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 14c913fb8..bbdafa4a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -4,11 +4,10 @@ import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.domain.user.UserService; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -17,7 +16,8 @@ public class LikeFacade { private final ProductService productService; private final LikeService likeService; - @Transactional + private static final int RETRY_COUNT = 30; + public LikeInfo like(long userId, long productId) { Optional existingLike = likeService.findLike(userId, productId); @@ -27,17 +27,48 @@ public LikeInfo like(long userId, long productId) { return LikeInfo.from(existingLike.get(), product.getLikeCount()); } - Product product = productService.getProductWithLock(productId); - Like newLike = likeService.save(userId, productId); - int updatedLikeCount = productService.increaseLikeCount(product); + for (int i = 0; i < RETRY_COUNT; i++) { + try { + + Like newLike = likeService.save(userId, productId); + int updatedLikeCount = productService.increaseLikeCount(productId); + + return LikeInfo.from(newLike, updatedLikeCount); + } catch (ObjectOptimisticLockingFailureException e) { + if (i == RETRY_COUNT - 1) { + throw e; + } + sleep(50); + } + } - return LikeInfo.from(newLike, updatedLikeCount); + throw new IllegalStateException("์ข‹์•„์š” ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); } - @Transactional public int unLike(long userId, long productId) { - likeService.unLike(userId, productId); - Product product = productService.getProductWithLock(productId); - return productService.decreaseLikeCount(product); + + for (int i = 0; i < RETRY_COUNT; i++) { + try { + likeService.unLike(userId, productId); + + return productService.decreaseLikeCount(productId); + } catch (ObjectOptimisticLockingFailureException e) { + if (i == RETRY_COUNT - 1) { + throw e; + } + sleep(50); + } + } + + throw new IllegalStateException("์‹ซ์–ด์š” ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + + private void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 4a7de11df..3ce3bcc90 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -3,6 +3,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Component @RequiredArgsConstructor @@ -18,6 +19,7 @@ public Optional findLike(long userId, long productId) { return likeRepository.findByUserIdAndProductId(userId, productId); } + @Transactional public void unLike(Long userId, Long productId) { likeRepository.deleteByUserIdAndProductId(userId, productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 56a7d8362..7bd29a48b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -13,6 +13,4 @@ public interface ProductRepository { Optional findById(Long id); Page findByBrandId(Long brandId, Pageable pageable); - - Optional findByIdWithLock(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 06fc12891..4275c78b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -23,6 +23,7 @@ public Page getProducts(Pageable pageable) { return productRepository.findAll(pageable); } + @Transactional public Product getProduct(Long id) { return productRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } @@ -54,16 +55,18 @@ public void deductStock(List products, List orderItems) { } @Transactional - public int increaseLikeCount(Product product) { - return product.increaseLikeCount(); - } + public int increaseLikeCount(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); - public int decreaseLikeCount(Product product) { - return product.decreaseLikeCount(); + return product.increaseLikeCount(); } - public Product getProductWithLock(Long id) { - return productRepository.findByIdWithLock(id) + @Transactional + public int decreaseLikeCount(Long productId) { + Product product = productRepository.findById(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return product.decreaseLikeCount(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index b675030b4..ad952361a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,19 +1,11 @@ package com.loopers.infrastructure.product; import com.loopers.domain.product.Product; -import jakarta.persistence.LockModeType; -import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; public interface ProductJpaRepository extends JpaRepository { Page findByBrandId(Long brandId, Pageable pageable); - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select p from Product p where p.id = :id") - Optional findByIdWithLock(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 4275f9c11..a1880faff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -33,9 +33,4 @@ public Optional findById(Long id) { public Page findByBrandId(Long brandId, Pageable pageable) { return productJpaRepository.findByBrandId(brandId, pageable); } - - @Override - public Optional findByIdWithLock(Long id) { - return productJpaRepository.findByIdWithLock(id); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java index b3071c654..cc36211d8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeConcurrencyTest.java @@ -8,7 +8,7 @@ import com.loopers.domain.user.User; import com.loopers.infrastructure.user.UserJpaRepository; import java.util.List; -import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -23,6 +23,7 @@ @SpringBootTest public class LikeConcurrencyTest { + @Autowired private LikeFacade likeFacade; @@ -41,7 +42,7 @@ void tearDown() { } @Nested - @DisplayName("์ข‹์•„์š” ์ฆ๊ฐ€ ๋™์‹œ์„ฑ (๋น„๊ด€์  ๋ฝ)") + @DisplayName("์ข‹์•„์š” ์ฆ๊ฐ€ ๋™์‹œ์„ฑ (๋‚™๊ด€์  ๋ฝ)") class LikeIncreaseConcurrency { private Product targetProduct; @@ -60,46 +61,40 @@ void setUp() { } @Test - @DisplayName("๋™์‹œ์— 10๋ช…์ด ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅด๋ฉด, ์ƒํ’ˆ์˜ ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” ์ •ํ™•ํžˆ 10๊ฐœ๊ฐ€ ๋˜์–ด์•ผ ํ•œ๋‹ค.") - void like_concurrency_test() throws InterruptedException { + @DisplayName("๋™์‹œ์— 10๋ช…์ด ์ข‹์•„์š”๋ฅผ ๋ˆŒ๋Ÿฌ๋„, ์ตœ์ข… ์ข‹์•„์š” ๊ฐœ์ˆ˜์™€ ์„ฑ๊ณต ์š”์ฒญ ์ˆ˜๋Š” ์ผ์น˜ํ•ด์•ผ ํ•œ๋‹ค.") + void like_concurrency_test() { // arrange int threadCount = 10; ExecutorService executorService = Executors.newFixedThreadPool(32); - CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(); AtomicInteger failCount = new AtomicInteger(); // act - for (int i = 0; i < threadCount; i++) { - final int index = i; - executorService.submit(() -> { - try { - likeFacade.like(users.get(index).getId(), targetProduct.getId()); - successCount.getAndIncrement(); - } catch (Exception e) { - System.out.println("์ข‹์•„์š” ์‹คํŒจ: " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); + List> futures = IntStream.range(0, threadCount) + .mapToObj(i -> CompletableFuture.runAsync(() -> { + try { + likeFacade.like(users.get(i).getId(), targetProduct.getId()); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ข‹์•„์š” ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } + }, executorService)) + .toList(); - Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow(); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - System.out.println("์ข‹์•„์š” ์„ฑ๊ณต: " + successCount.get() + ", ์‹คํŒจ: " + failCount.get()); + Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow(); // assert - assertThat(successCount.get()).isEqualTo(threadCount); - assertThat(resultProduct.getLikeCount()).isEqualTo(10); + assertThat(resultProduct.getLikeCount()).isEqualTo(successCount.get()); + assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount); } } @Nested - @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ๋™์‹œ์„ฑ (๋น„๊ด€์  ๋ฝ)") + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ ๋™์‹œ์„ฑ (๋‚™๊ด€์  ๋ฝ)") class LikeDecreaseConcurrency { private Product targetProduct; @@ -116,14 +111,15 @@ void setUp() { )) .toList(); - for (User user : users) { - likeFacade.like(user.getId(), targetProduct.getId()); - } + users.forEach(user -> likeFacade.like(user.getId(), targetProduct.getId())); + + Product initial = productRepository.findById(targetProduct.getId()).orElseThrow(); + assertThat(initial.getLikeCount()).isEqualTo(10); } @Test @DisplayName("์ด๋ฏธ ์ข‹์•„์š”๋ฅผ ๋ˆ„๋ฅธ 10๋ช…์ด ๋™์‹œ์— ์ทจ์†Œ๋ฅผ ์š”์ฒญํ•˜๋ฉด, ์ข‹์•„์š” ๊ฐœ์ˆ˜๋Š” 0๊ฐœ๊ฐ€ ๋˜์–ด์•ผ ํ•œ๋‹ค.") - void unlike_concurrency_test() throws InterruptedException { + void unlike_concurrency_test() { // arrange int threadCount = 10; @@ -131,37 +127,30 @@ void unlike_concurrency_test() throws InterruptedException { assertThat(initialProduct.getLikeCount()).isEqualTo(10); ExecutorService executorService = Executors.newFixedThreadPool(32); - CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(); AtomicInteger failCount = new AtomicInteger(); // act - for (int i = 0; i < threadCount; i++) { - final int index = i; - executorService.submit(() -> { - try { - likeFacade.unLike(users.get(index).getId(), targetProduct.getId()); - successCount.getAndIncrement(); - } catch (Exception e) { - System.out.println("์ทจ์†Œ ์‹คํŒจ: " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); + List> futures = IntStream.range(0, threadCount) + .mapToObj(i -> CompletableFuture.runAsync(() -> { + try { + likeFacade.unLike(users.get(i).getId(), targetProduct.getId()); + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ทจ์†Œ ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } + }, executorService)) + .toList(); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); Product resultProduct = productRepository.findById(targetProduct.getId()).orElseThrow(); - System.out.println("์ทจ์†Œ ์„ฑ๊ณต: " + successCount.get() + ", ์‹คํŒจ: " + failCount.get()); - // assert - assertThat(successCount.get()).isEqualTo(threadCount); assertThat(resultProduct.getLikeCount()).isZero(); + assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index d77cb4360..f6618c9f6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -12,7 +12,7 @@ import com.loopers.infrastructure.product.ProductJpaRepository; import com.loopers.infrastructure.user.UserJpaRepository; import java.util.List; -import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -69,40 +69,36 @@ void setUp() { @Test @DisplayName("๋™์‹œ์— 10๋ฒˆ ์ฃผ๋ฌธ ์‹œ, ๋น„๊ด€์  ๋ฝ์œผ๋กœ ์ธํ•ด ํฌ์ธํŠธ๋Š” ์ˆœ์ฐจ์ ์œผ๋กœ ์ •ํ™•ํžˆ ์ฐจ๊ฐ๋˜์–ด์•ผ ํ•œ๋‹ค.") - void point_deduction_concurrency_test() throws InterruptedException { + void point_deduction_concurrency_test() { // arrange int threadCount = 10; - ExecutorService executorService = Executors.newFixedThreadPool(32); - CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(); AtomicInteger failCount = new AtomicInteger(); // act - for (int i = 0; i < threadCount; i++) { - final int index = i; - executorService.submit(() -> { - try { - Product targetProduct = distinctProducts.get(index); - PlaceOrder command = new PlaceOrder(savedUser.getId(), - List.of(new Item(targetProduct.getId(), 1))); - orderFacade.placeOrder(command); - successCount.getAndIncrement(); - } catch (Exception e) { - System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); + List> futures = IntStream.range(0, threadCount) + .mapToObj(i -> CompletableFuture.runAsync(() -> { + try { + Product targetProduct = distinctProducts.get(i); + PlaceOrder command = new PlaceOrder(savedUser.getId(), + List.of(new Item(targetProduct.getId(), 1))); + + orderFacade.placeOrder(command); + + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ฃผ๋ฌธ ์‹คํŒจ: " + e.getMessage()); + failCount.getAndIncrement(); + } + }, executorService)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); // assert User findUser = userRepository.findById(savedUser.getId()).orElseThrow(); - long expectedPoint = 10000L - (1000L * threadCount); assertThat(successCount.get()).isEqualTo(threadCount); @@ -118,13 +114,13 @@ class StockConcurrency { @BeforeEach void setUp() { - Product product = new Product(1l, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(1000L), 100); + Product product = new Product(1L, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(1000L), 100); this.savedProduct = productJpaRepository.saveAndFlush(product); } @Test @DisplayName("์„œ๋กœ ๋‹ค๋ฅธ 10๋ช…์ด ๋™์‹œ์— ์ฃผ๋ฌธ ์‹œ, ๋‚™๊ด€์  ๋ฝ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ•ด๋„ ์žฌ๊ณ  ์ •ํ•ฉ์„ฑ์€ ์œ ์ง€๋˜์–ด์•ผ ํ•œ๋‹ค.") - void stock_deduction_concurrency_test() throws InterruptedException { + void stock_deduction_concurrency_test() { // arrange int threadCount = 10; @@ -137,35 +133,31 @@ void stock_deduction_concurrency_test() throws InterruptedException { .toList(); ExecutorService executorService = Executors.newFixedThreadPool(32); - CountDownLatch latch = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(); AtomicInteger failCount = new AtomicInteger(); // act - for (int i = 0; i < threadCount; i++) { - final int index = i; - executorService.submit(() -> { - try { - PlaceOrder command = new PlaceOrder(multiUsers.get(index).getId(), - List.of(new Item(savedProduct.getId(), 1))); - orderFacade.placeOrder(command); - - successCount.getAndIncrement(); - } catch (Exception e) { - System.out.println("์ฃผ๋ฌธ ์‹คํŒจ(์ถฉ๋Œ ๊ฐ์ง€): " + e.getMessage()); - failCount.getAndIncrement(); - } finally { - latch.countDown(); - } - }); - } - - latch.await(); + List> futures = IntStream.range(0, threadCount) + .mapToObj(i -> CompletableFuture.runAsync(() -> { + try { + PlaceOrder command = new PlaceOrder(multiUsers.get(i).getId(), + List.of(new Item(savedProduct.getId(), 1))); + + orderFacade.placeOrder(command); + + successCount.getAndIncrement(); + } catch (Exception e) { + System.out.println("์ฃผ๋ฌธ ์‹คํŒจ(์ถฉ๋Œ ๊ฐ์ง€): " + e.getMessage()); + failCount.getAndIncrement(); + } + }, executorService)) + .toList(); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); // assert Product findProduct = productJpaRepository.findById(savedProduct.getId()).orElseThrow(); - long expectedStock = savedProduct.getStock() - successCount.get(); System.out.println("์„ฑ๊ณต: " + successCount.get() + ", ์ถฉ๋Œ ์‹คํŒจ: " + failCount.get()); From 2c183aa73ea02d2c31a78d1e1dd9dfbf803ece05 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 21 Nov 2025 11:46:29 +0900 Subject: [PATCH 100/164] =?UTF-8?q?feature:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EA=B3=84=20=EB=B0=8F=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/coupon/Coupon.java | 55 +++++++++ .../com/loopers/domain/coupon/CouponType.java | 18 +++ .../com/loopers/domain/coupon/CouponTest.java | 110 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java new file mode 100644 index 000000000..42dcd3e6f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -0,0 +1,55 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "coupon") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Coupon extends BaseEntity { + + @Column(name = "ref_user_id", nullable = false) + private long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CouponType type; + + private long discountValue; + + private boolean used; + + public Coupon(long userId, CouponType type, long discountValue) { + if (type == CouponType.PERCENTAGE && (discountValue < 0 || discountValue > 100)) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํ• ์ธ์œจ์€ 0~100% ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + this.userId = userId; + this.type = type; + this.discountValue = discountValue; + this.used = false; + } + + public boolean isUsed() { + return used; + } + + public long calculateDiscountAmount(long totalOrderAmount) { + return this.type.calculate(totalOrderAmount, this.discountValue); + } + + public void use() { + if (this.used) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."); + } + this.used = true; + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java new file mode 100644 index 000000000..4a6f12c73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponType.java @@ -0,0 +1,18 @@ +package com.loopers.domain.coupon; + +public enum CouponType { + FIXED_AMOUNT { + @Override + public long calculate(long totalAmount, long discountValue) { + return Math.min(totalAmount, discountValue); + } + }, + PERCENTAGE { + @Override + public long calculate(long totalAmount, long discountValue) { + return (long) (totalAmount * (discountValue / 100.0)); + } + }; + + public abstract long calculate(long totalAmount, long discountValue); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java new file mode 100644 index 000000000..1574e50da --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class CouponTest { + @Nested + @DisplayName("์ฟ ํฐ ํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ ํ…Œ์ŠคํŠธ") + class CalculateDiscount { + + @Test + @DisplayName("์ •์•ก ์ฟ ํฐ: ์ฃผ๋ฌธ ๊ธˆ์•ก์—์„œ ํ• ์ธ ๊ธˆ์•ก๋งŒํผ ์ฐจ๊ฐ๋œ ๊ฐ’์„ ๊ณ„์‚ฐํ•œ๋‹ค.") + void fixed_amount_discount() { + // given + long orderAmount = 10000L; + long discountAmount = 1000L; + Coupon coupon = new Coupon(1l, CouponType.FIXED_AMOUNT, discountAmount); + + // when + long result = coupon.calculateDiscountAmount(orderAmount); + + // then + assertThat(result).isEqualTo(1000L); + } + + @Test + @DisplayName("์ •์•ก ์ฟ ํฐ: ์ฃผ๋ฌธ ๊ธˆ์•ก๋ณด๋‹ค ํ• ์ธ ๊ธˆ์•ก์ด ํฌ๋ฉด, ์ฃผ๋ฌธ ๊ธˆ์•ก๋งŒํผ๋งŒ ํ• ์ธ๋œ๋‹ค(๊ฒฐ์ œ๊ธˆ์•ก 0์› ๋ณด์žฅ).") + void fixed_amount_discount_capped() { + // given + long orderAmount = 500L; + long discountAmount = 1000L; + Coupon coupon = new Coupon(1l, CouponType.FIXED_AMOUNT, discountAmount); + + // when + long result = coupon.calculateDiscountAmount(orderAmount); + + // then + assertThat(result).isEqualTo(500L); + } + + @Test + @DisplayName("์ •๋ฅ  ์ฟ ํฐ: ์ฃผ๋ฌธ ๊ธˆ์•ก์˜ ๋น„์œจ๋งŒํผ ํ• ์ธ ๊ธˆ์•ก์ด ๊ณ„์‚ฐ๋œ๋‹ค.") + void percentage_discount() { + // given + long orderAmount = 20000L; + long discountRate = 10L; // 10% + Coupon coupon = new Coupon(1l, CouponType.PERCENTAGE, discountRate); + + // when + long result = coupon.calculateDiscountAmount(orderAmount); + + // then + assertThat(result).isEqualTo(2000L); + } + } + + @Nested + @DisplayName("์ฟ ํฐ ์ƒ์„ฑ ๋ฐ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ") + class Validation { + + @Test + @DisplayName("์ •๋ฅ  ์ฟ ํฐ ์ƒ์„ฑ ์‹œ ํ• ์ธ์œจ์ด 100์„ ์ดˆ๊ณผํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void create_percentage_coupon_fail_over_100() { + assertThatThrownBy(() -> + new Coupon(1l, CouponType.PERCENTAGE, 101L) + ) + .isInstanceOf(CoreException.class) + .hasMessage("ํ• ์ธ์œจ์€ 0~100% ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + @Nested + @DisplayName("์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ ํ…Œ์ŠคํŠธ") + class UseCoupon { + + @Test + @DisplayName("์ฟ ํฐ์„ ์‚ฌ์šฉํ•˜๋ฉด used ์ƒํƒœ๊ฐ€ true๋กœ ๋ณ€๊ฒฝ๋œ๋‹ค.") + void use_coupon_success() { + // given + Coupon coupon = new Coupon(1l, CouponType.FIXED_AMOUNT, 1000L); + + // when + coupon.use(); + + // then + assertThat(coupon.isUsed()).isTrue(); + } + + @Test + @DisplayName("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์„ ๋‹ค์‹œ ์‚ฌ์šฉํ•˜๋ ค๊ณ  ํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + void use_coupon_fail_already_used() { + // given + Coupon coupon = new Coupon(1l, CouponType.FIXED_AMOUNT, 1000L); + coupon.use(); + + // when & then + assertThatThrownBy(coupon::use) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST) + .hasMessage("์ด๋ฏธ ์‚ฌ์šฉ๋œ ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."); + } + } +} From 6a55717de8bbdc0932331a29b87d1e33fd64b491 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 21 Nov 2025 13:22:23 +0900 Subject: [PATCH 101/164] =?UTF-8?q?feature:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=86=B5=ED=95=A9=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/coupon/CouponRepository.java | 10 +++ .../loopers/domain/coupon/CouponService.java | 19 ++++++ .../coupon/CouponJpaRepository.java | 8 +++ .../coupon/CouponRepositoryImpl.java | 24 +++++++ .../coupon/CouponServiceIntegrationTest.java | 67 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java new file mode 100644 index 000000000..4a534f0ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.coupon; + +import java.util.Optional; + +public interface CouponRepository { + + Coupon save(Coupon coupon); + + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java new file mode 100644 index 000000000..fb200e9b6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -0,0 +1,19 @@ +package com.loopers.domain.coupon; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CouponService { + + private final CouponRepository couponRepository; + + + public Coupon getCoupon(Long id) { + return couponRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..858ac222a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CouponJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java new file mode 100644 index 000000000..fecbcf4a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/CouponRepositoryImpl.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.Coupon; +import com.loopers.domain.coupon.CouponRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class CouponRepositoryImpl implements CouponRepository { + + private final CouponJpaRepository couponJpaRepository; + + @Override + public Coupon save(Coupon coupon) { + return couponJpaRepository.save(coupon); + } + + @Override + public Optional findById(Long id) { + return couponJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceIntegrationTest.java new file mode 100644 index 000000000..47639c450 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponServiceIntegrationTest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.coupon; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.User.Gender; +import com.loopers.domain.user.UserRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@Transactional +class CouponServiceIntegrationTest { + + @Autowired + CouponService couponService; + + @MockitoSpyBean + CouponRepository couponRepository; + + @MockitoSpyBean + UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("์ฟ ํฐ ์กฐํšŒ ์‹œ,") + @Nested + class GetCoupon { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ฟ ํฐ์ด๋ฉด ์กฐํšŒ๋œ๋‹ค.") + @Test + void getCoupon_success_whenExists() { + // arrange + User user = userRepository.save(new User("user1", "a@email.com", "2025-11-11", Gender.FEMALE)); + Coupon coupon = couponRepository.save( + new Coupon(user.getId(), CouponType.FIXED_AMOUNT, 10) + ); + + // act + Coupon found = couponService.getCoupon(coupon.getId()); + + // assert + assertThat(found).isNotNull(); + assertThat(found.getId()).isEqualTo(coupon.getId()); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฟ ํฐ์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค.") + @Test + void getCoupon_fail_whenNotFound() { + assertThrows(RuntimeException.class, () -> couponService.getCoupon(999L)); + } + } +} From a230acbbe9c5a3ff5978acc0f0643d83ecc0f3fc Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 21 Nov 2025 14:01:28 +0900 Subject: [PATCH 102/164] =?UTF-8?q?refactor:=20=EB=B6=84=EB=A6=AC=EB=90=9C?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=AC=B6=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/application/like/LikeFacade.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index bbdafa4a3..b51998622 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; @RequiredArgsConstructor @Component @@ -15,6 +16,7 @@ public class LikeFacade { private final ProductService productService; private final LikeService likeService; + private final TransactionTemplate transactionTemplate; private static final int RETRY_COUNT = 30; @@ -46,20 +48,25 @@ public LikeInfo like(long userId, long productId) { } public int unLike(long userId, long productId) { - for (int i = 0; i < RETRY_COUNT; i++) { try { - likeService.unLike(userId, productId); - return productService.decreaseLikeCount(productId); + return transactionTemplate.execute(status -> { + + likeService.unLike(userId, productId); + + return productService.decreaseLikeCount(productId); + + }); + } catch (ObjectOptimisticLockingFailureException e) { + if (i == RETRY_COUNT - 1) { throw e; } sleep(50); } } - throw new IllegalStateException("์‹ซ์–ด์š” ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); } From bf668de87570c53e86a99ca6d4cafa7185c13089 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 26 Nov 2025 11:56:30 +0900 Subject: [PATCH 103/164] =?UTF-8?q?docs:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=92=88=20=EB=8D=94=EB=AF=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=A4=80=EB=B9=84(10=EB=A7=8C=EA=B0=9C=20=EC=A0=9C?= =?UTF-8?q?=ED=92=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/resources/db/data.sql | 57 +++++++++++++++++++ .../src/test/resources/db/schema.sql | 23 ++++++++ 2 files changed, 80 insertions(+) create mode 100644 apps/commerce-api/src/test/resources/db/data.sql create mode 100644 apps/commerce-api/src/test/resources/db/schema.sql diff --git a/apps/commerce-api/src/test/resources/db/data.sql b/apps/commerce-api/src/test/resources/db/data.sql new file mode 100644 index 000000000..0276f05c7 --- /dev/null +++ b/apps/commerce-api/src/test/resources/db/data.sql @@ -0,0 +1,57 @@ +INSERT INTO brand (name, description, created_at, updated_at) VALUES +('Nike', 'Sports Brand', NOW(), NOW()), +('Adidas', 'Sports Brand', NOW(), NOW()), +('Apple', 'Tech Brand', NOW(), NOW()), +('Samsung', 'Tech Brand', NOW(), NOW()), +('Sony', 'Electronics', NOW(), NOW()), +('LG', 'Electronics', NOW(), NOW()), +('Chanel', 'Luxury', NOW(), NOW()), +('Gucci', 'Luxury', NOW(), NOW()), +('Zara', 'Clothing', NOW(), NOW()), +('Uniqlo', 'Clothing', NOW(), NOW()); + +DROP PROCEDURE IF EXISTS loop_insert_products; + +DELIMITER $$ +CREATE PROCEDURE loop_insert_products() +BEGIN + DECLARE i INT DEFAULT 1; + + SET autocommit = 0; + + WHILE i <= 100000 DO + INSERT INTO product ( + brand_id, + name, + description, + price_amount, + stock, + like_count, + version, + created_at, + updated_at + ) VALUES ( + FLOOR(1 + RAND() * 10), + CONCAT('Product Name ', i), + CONCAT('Description for product ', i), + FLOOR(1000 + RAND() * 99000), + FLOOR(RAND() * 100), + FLOOR(RAND() * 5000), + 0, + NOW(), + NOW() + ); + + SET i = i + 1; + + IF i % 1000 = 0 THEN + COMMIT; + END IF; + END WHILE; + + COMMIT; + SET autocommit = 1; +END$$ +DELIMITER ; + +CALL loop_insert_products(); \ No newline at end of file diff --git a/apps/commerce-api/src/test/resources/db/schema.sql b/apps/commerce-api/src/test/resources/db/schema.sql new file mode 100644 index 000000000..d8bea0b04 --- /dev/null +++ b/apps/commerce-api/src/test/resources/db/schema.sql @@ -0,0 +1,23 @@ +DROP TABLE IF EXISTS brand; +DROP TABLE IF EXISTS product; + +CREATE TABLE brand ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description VARCHAR(500) NOT NULL, + created_at DATETIME, + updated_at DATETIME +); + +CREATE TABLE product ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + brand_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + description VARCHAR(500) NOT NULL, + price BIGINT NOT NULL, + stock INT NOT NULL, + like_count INT NOT NULL DEFAULT 0, + version BIGINT DEFAULT 0, + created_at DATETIME, + updated_at DATETIME +); From adc3f43b75730b55974454a0c25b148b903296e6 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 26 Nov 2025 20:11:30 +0900 Subject: [PATCH 104/164] =?UTF-8?q?perf:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=A0=95=EB=A0=AC?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=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 --- apps/commerce-api/src/test/resources/db/index.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 apps/commerce-api/src/test/resources/db/index.sql diff --git a/apps/commerce-api/src/test/resources/db/index.sql b/apps/commerce-api/src/test/resources/db/index.sql new file mode 100644 index 000000000..e00d871b8 --- /dev/null +++ b/apps/commerce-api/src/test/resources/db/index.sql @@ -0,0 +1,4 @@ +CREATE INDEX idx_product_brand_like +ON product (brand_id, like_count DESC); + +CREATE INDEX idx_product_brand ON product (brand_id); \ No newline at end of file From 38005dd3232dbe372363297291726e301d9caf91 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 27 Nov 2025 22:51:34 +0900 Subject: [PATCH 105/164] =?UTF-8?q?docs:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=BF=BC=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/resources/db/data.sql | 35 +++++++++++++++---- .../src/test/resources/db/schema.sql | 23 ------------ 2 files changed, 29 insertions(+), 29 deletions(-) delete mode 100644 apps/commerce-api/src/test/resources/db/schema.sql diff --git a/apps/commerce-api/src/test/resources/db/data.sql b/apps/commerce-api/src/test/resources/db/data.sql index 0276f05c7..52e048596 100644 --- a/apps/commerce-api/src/test/resources/db/data.sql +++ b/apps/commerce-api/src/test/resources/db/data.sql @@ -8,11 +8,32 @@ INSERT INTO brand (name, description, created_at, updated_at) VALUES ('Chanel', 'Luxury', NOW(), NOW()), ('Gucci', 'Luxury', NOW(), NOW()), ('Zara', 'Clothing', NOW(), NOW()), -('Uniqlo', 'Clothing', NOW(), NOW()); +('Uniqlo', 'Clothing', NOW(), NOW()), +('Puma', 'Sports Brand', NOW(), NOW()), +('Reebok', 'Sports Brand', NOW(), NOW()), +('Microsoft', 'Tech Brand', NOW(), NOW()), +('Amazon', 'E-commerce', NOW(), NOW()), +('Meta', 'Tech Brand', NOW(), NOW()), +('Google', 'Tech Brand', NOW(), NOW()), +('Lenovo', 'Electronics', NOW(), NOW()), +('Asus', 'Electronics', NOW(), NOW()), +('Dell', 'Electronics', NOW(), NOW()), +('HP', 'Electronics', NOW(), NOW()), +('Prada', 'Luxury', NOW(), NOW()), +('Hermes', 'Luxury', NOW(), NOW()), +('Burberry', 'Luxury', NOW(), NOW()), +('Coach', 'Luxury', NOW(), NOW()), +('H&M', 'Clothing', NOW(), NOW()), +('Bershka', 'Clothing', NOW(), NOW()), +('Pull&Bear', 'Clothing', NOW(), NOW()), +('New Balance', 'Sports Brand', NOW(), NOW()), +('The North Face', 'Outdoor Brand', NOW(), NOW()), +('Columbia', 'Outdoor Brand', NOW(), NOW()); DROP PROCEDURE IF EXISTS loop_insert_products; DELIMITER $$ + CREATE PROCEDURE loop_insert_products() BEGIN DECLARE i INT DEFAULT 1; @@ -24,19 +45,19 @@ BEGIN brand_id, name, description, - price_amount, + price, stock, like_count, version, created_at, updated_at ) VALUES ( - FLOOR(1 + RAND() * 10), + FLOOR(1 + RAND() * 30), CONCAT('Product Name ', i), - CONCAT('Description for product ', i), + CONCAT('Description ', i), FLOOR(1000 + RAND() * 99000), - FLOOR(RAND() * 100), - FLOOR(RAND() * 5000), + FLOOR(RAND() * 1000), + FLOOR(RAND() * 500), 0, NOW(), NOW() @@ -52,6 +73,8 @@ BEGIN COMMIT; SET autocommit = 1; END$$ + DELIMITER ; + CALL loop_insert_products(); \ No newline at end of file diff --git a/apps/commerce-api/src/test/resources/db/schema.sql b/apps/commerce-api/src/test/resources/db/schema.sql deleted file mode 100644 index d8bea0b04..000000000 --- a/apps/commerce-api/src/test/resources/db/schema.sql +++ /dev/null @@ -1,23 +0,0 @@ -DROP TABLE IF EXISTS brand; -DROP TABLE IF EXISTS product; - -CREATE TABLE brand ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(255) NOT NULL, - description VARCHAR(500) NOT NULL, - created_at DATETIME, - updated_at DATETIME -); - -CREATE TABLE product ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - brand_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - description VARCHAR(500) NOT NULL, - price BIGINT NOT NULL, - stock INT NOT NULL, - like_count INT NOT NULL DEFAULT 0, - version BIGINT DEFAULT 0, - created_at DATETIME, - updated_at DATETIME -); From 24eee7631b291157a58a6c231f50ea97461dc1cf Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 27 Nov 2025 22:52:40 +0900 Subject: [PATCH 106/164] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=EB=AA=85?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/product/Product.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 690fde127..cae0cd0a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -4,6 +4,7 @@ import com.loopers.domain.money.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; @@ -26,7 +27,7 @@ public class Product extends BaseEntity { @Column(nullable = false) private String description; - @Column(nullable = false) + @AttributeOverride(name = "value", column = @Column(name = "price")) @Embedded private Money price; From 41e3b03c7b3f67344dfc29a53085385b6a8efa40 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 27 Nov 2025 22:55:06 +0900 Subject: [PATCH 107/164] =?UTF-8?q?chore:jpa=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/src/main/resources/jpa.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 apps/commerce-api/src/main/resources/jpa.yml diff --git a/apps/commerce-api/src/main/resources/jpa.yml b/apps/commerce-api/src/main/resources/jpa.yml new file mode 100644 index 000000000..d9eb878cb --- /dev/null +++ b/apps/commerce-api/src/main/resources/jpa.yml @@ -0,0 +1,19 @@ +datasource: + mysql-jpa: + main: + jdbc-url: "jdbc:mysql://127.0.0.1:3306/loopers?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true" + username: application + password: application + driver-class-name: com.mysql.cj.jdbc.Driver + maximum-pool-size: 10 + minimum-idle: 5 + pool-name: MyHikariCP + +spring: + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true \ No newline at end of file From 3b2ed9efdc0dd4340a04b99a3f2ba236b4c39fdb Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 11:06:37 +0900 Subject: [PATCH 108/164] =?UTF-8?q?chore:redis=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/config/redis/RedisConfig.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java diff --git a/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java new file mode 100644 index 000000000..dfec017a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java @@ -0,0 +1,61 @@ +package com.loopers.config.redis; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.util.List; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + ObjectMapper objectMapper = new ObjectMapper(); + + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + objectMapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class) + .build(), + ObjectMapper.DefaultTyping.EVERYTHING + ); + + objectMapper.addMixIn(PageImpl.class, PageImplMixin.class); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(serializer); + + return template; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + abstract static class PageImplMixin { + @JsonCreator + public PageImplMixin(@JsonProperty("content") List content, + @JsonProperty("pageable") JsonNode pageable, + @JsonProperty("total") long total) { + } + } +} From b4b8a611175a1ed705012499ec89a3ab071edfba Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 11:07:52 +0900 Subject: [PATCH 109/164] =?UTF-8?q?refactor:=EC=BA=90=EC=8B=9C=EB=AF=B8?= =?UTF-8?q?=EC=8A=A4=EC=8B=9C=20db=20=EC=A1=B0=ED=9A=8C=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EA=B3=B5=ED=86=B5=20=EB=A1=9C=EC=A7=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/cache/RedisCacheHandler.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java new file mode 100644 index 000000000..2c6c41e7e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java @@ -0,0 +1,52 @@ +package com.loopers.support.cache; + +import com.loopers.support.page.PageWrapper; +import java.time.Duration; +import java.util.function.Supplier; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class RedisCacheHandler { + private final RedisTemplate redisTemplate; + + /** + * ์บ์‹œ ์กฐํšŒ -> (์—†๊ฑฐ๋‚˜ ์—๋Ÿฌ๋‚˜๋ฉด) -> DB ์กฐํšŒ -> ์บ์‹œ ์ €์žฅ + * @param key ์บ์‹œ ํ‚ค + * @param ttl ๋งŒ๋ฃŒ ์‹œ๊ฐ„ + * @param type ๋ฐ˜ํ™˜ ํƒ€์ž… (์บ์ŠคํŒ…์šฉ) + * @param dbFetcher DB ์กฐํšŒ ๋กœ์ง (๋žŒ๋‹ค) + */ + public T getOrLoad(String key, Duration ttl, Class type, Supplier dbFetcher) { + try { + Object cachedData = redisTemplate.opsForValue().get(key); + if (cachedData != null) { + + if (cachedData instanceof PageWrapper) { + return (T) ((PageWrapper) cachedData).toPage(); + } + return type.cast(cachedData); + } + } catch (Exception e) { + } + + T result = dbFetcher.get(); + + if (result != null) { + try { + Object dataToSave = result; + if (result instanceof Page) { + dataToSave = new PageWrapper<>((Page) result); + } + + redisTemplate.opsForValue().set(key, dataToSave, ttl); + } catch (Exception e) { + } + } + + return result; + } +} From 0207881734c9fa7628bb66b3458e675d8a188fd8 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 11:08:47 +0900 Subject: [PATCH 110/164] =?UTF-8?q?chore:=EB=A0=88=EB=94=94=EC=8A=A4=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20yml=20=ED=8C=8C=EC=9D=BC=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 --- apps/commerce-api/src/main/resources/redis.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/commerce-api/src/main/resources/redis.yml diff --git a/apps/commerce-api/src/main/resources/redis.yml b/apps/commerce-api/src/main/resources/redis.yml new file mode 100644 index 000000000..f1f71fd1f --- /dev/null +++ b/apps/commerce-api/src/main/resources/redis.yml @@ -0,0 +1,7 @@ +datasource: + redis: + database: 0 + master: + host: 127.0.0.1 + port: 6379 + replicas: [] \ No newline at end of file From e34e1107d26470fc01efdeceab25e7109a499b89 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 11:10:16 +0900 Subject: [PATCH 111/164] =?UTF-8?q?feature:=EC=83=81=ED=92=88=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D,=20=EC=83=81=ED=92=88=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 11 +- .../domain/product/ProductService.java | 43 ++++- .../product/ProductFacadeIntegrationTest.java | 4 +- .../domain/product/ProductCacheTest.java | 149 ++++++++++++++++++ 4 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductCacheTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 7d7aed779..1982900da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -11,10 +11,11 @@ @RequiredArgsConstructor @Component public class ProductFacade { + private final ProductService productService; private final BrandService brandService; - public Page getProductInfo(Pageable pageable) { + public Page getProductsInfo(Pageable pageable) { Page products = productService.getProducts(pageable); return products.map(product -> { String brandName = brandService.getBrand(product.getBrandId()) @@ -23,4 +24,12 @@ public Page getProductInfo(Pageable pageable) { }); } + public ProductInfo getProductInfo(long id) { + Product product = productService.getProduct(id); + String brandName = brandService.getBrand(product.getBrandId()) + .getName(); + + return ProductInfo.from(product, brandName); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 4275c78b0..7a52560d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,8 +1,10 @@ package com.loopers.domain.product; import com.loopers.domain.order.OrderItem; +import com.loopers.support.cache.RedisCacheHandler; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -18,19 +20,38 @@ public class ProductService { private final ProductRepository productRepository; + private final RedisCacheHandler redisCacheHandler; public Page getProducts(Pageable pageable) { - return productRepository.findAll(pageable); + String key = makeCacheKey("product:list", pageable); + return redisCacheHandler.getOrLoad( + key, + Duration.ofMinutes(5), + Page.class, + () -> productRepository.findAll(pageable) + ); } @Transactional public Product getProduct(Long id) { - return productRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + String key = "product:detail:" + id; + return redisCacheHandler.getOrLoad( + key, + Duration.ofMinutes(10), + Product.class, + () -> productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")) + ); } public Page getProductsByBrandId(Long brandId, Pageable pageable) { - - return productRepository.findByBrandId(brandId, pageable); + String key = makeCacheKey("product:list:brand:" + brandId, pageable); + return redisCacheHandler.getOrLoad( + key, + Duration.ofMinutes(5), + Page.class, + () -> productRepository.findByBrandId(brandId, pageable) + ); } @@ -69,4 +90,18 @@ public int decreaseLikeCount(Long productId) { return product.decreaseLikeCount(); } + + private String makeCacheKey(String prefix, Pageable pageable) { + StringBuilder sb = new StringBuilder(); + sb.append(prefix); + sb.append(":page:").append(pageable.getPageNumber()); + sb.append(":size:").append(pageable.getPageSize()); + + if (pageable.getSort().isSorted()) { + pageable.getSort().forEach(order -> + sb.append(":sort:").append(order.getProperty()).append(",").append(order.getDirection()) + ); + } + return sb.toString(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java index acbe3f667..de26b8f18 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeIntegrationTest.java @@ -56,7 +56,7 @@ void return_productInfoPage_withBrandNames() { Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); // act - Page result = productFacade.getProductInfo(pageable); + Page result = productFacade.getProductsInfo(pageable); // assert assertAll( @@ -72,7 +72,7 @@ void return_emptyPage_whenNoProductsExist() { Pageable pageable = PageRequest.of(0, 10); // act - Page result = productFacade.getProductInfo(pageable); + Page result = productFacade.getProductsInfo(pageable); // assert assertThat(result.isEmpty()).isTrue(); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductCacheTest.java new file mode 100644 index 000000000..88641db6c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductCacheTest.java @@ -0,0 +1,149 @@ +package com.loopers.domain.product; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doThrow; + +import com.loopers.domain.money.Money; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@SpringBootTest +public class ProductCacheTest { + @Autowired + ProductService productService; + + @Autowired + ProductRepository productRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + DatabaseCleanUp databaseCleanUp; + + @MockitoSpyBean + RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + + Set keys = redisTemplate.keys("product:*"); + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + @DisplayName("์บ์‹œ ๋™์ž‘ ๊ฒ€์ฆ") + @Nested + class Cache { + + @DisplayName("DB ์กฐํšŒ ํ›„ ๊ฒฐ๊ณผ๊ฐ€ Redis์— ์ €์žฅ๋˜๋ฉฐ, DB ๋ฐ์ดํ„ฐ๊ฐ€ ์‚ญ์ œ๋˜์–ด๋„ ์บ์‹œ์—์„œ ์กฐํšŒ๋œ๋‹ค.") + @Test + void return_cachedData_whenDbDataDeleted() { + // arrange + Long brandId = 1L; + Product product = new Product(brandId, "์บ์‹œ์ƒํ’ˆ", "์„ค๋ช…", new Money(10000L), 10); + productRepository.save(product); + + Pageable pageable = PageRequest.of(0, 10, Sort.by("id").descending()); + String expectedKey = "product:list:brand:" + brandId + ":page:0:size:10:sort:id,DESC"; + + // act 1: ์ฒซ ๋ฒˆ์งธ ์กฐํšŒ (Cache Miss -> DB ์กฐํšŒ -> Redis ์ €์žฅ) + productService.getProductsByBrandId(brandId, pageable); + + // assert 1: Redis์— ํ‚ค๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + assertTrue(redisTemplate.hasKey(expectedKey), "Redis์— ์บ์‹œ ํ‚ค๊ฐ€ ์ƒ์„ฑ๋˜์–ด์•ผ ํ•จ"); + + // act 2: DB ๋ฐ์ดํ„ฐ ๊ฐ•์ œ ์‚ญ์ œ (๋ณ€์ˆ˜ ์ฐฝ์ถœ) + productJpaRepository.deleteAll(); + + // act 3: ๋‘ ๋ฒˆ์งธ ์กฐํšŒ (Cache Hit -> Redis ์กฐํšŒ) + Page secondResult = productService.getProductsByBrandId(brandId, pageable); + + // assert 2: DB๋Š” ๋น„์—ˆ์ง€๋งŒ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•จ + assertAll("์บ์‹œ ์กฐํšŒ ๊ฒ€์ฆ", + () -> assertEquals(1, secondResult.getTotalElements(), "DB ์‚ญ์ œ ํ›„์—๋„ 1๊ฐœ๊ฐ€ ์กฐํšŒ๋˜์–ด์•ผ ํ•จ"), + () -> assertEquals("์บ์‹œ์ƒํ’ˆ", secondResult.getContent().get(0).getName(), "์บ์‹œ๋œ ์ƒํ’ˆ๋ช… ์ผ์น˜ ํ™•์ธ") + ); + } + + @DisplayName("Redis ์—ฐ๊ฒฐ ์žฅ์• ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ์„œ๋น„์Šค๋Š” DB๋ฅผ ํ†ตํ•ด ์ •์ƒ์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค (Fail-Safe).") + @Test + void return_dataFromDb_whenRedisConnectionFails() { + // arrange + Long brandId = 2L; + Product product = new Product(brandId, "์žฅ์• ๋Œ€์‘์ƒํ’ˆ", "์„ค๋ช…", new Money(20000L), 20); + productRepository.save(product); + + Pageable pageable = PageRequest.of(0, 10); + + + ValueOperations ops = redisTemplate.opsForValue(); + + doThrow(new RedisConnectionFailureException("Redis ์—ฐ๊ฒฐ ๋ถˆ๊ฐ€")) + .when(redisTemplate).opsForValue(); + + // act + Page result = productService.getProductsByBrandId(brandId, pageable); + + // assert + assertAll("์žฅ์•  ๋Œ€์‘ ๊ฒ€์ฆ", + () -> assertEquals(1, result.getTotalElements(), "Redis ์—๋Ÿฌ ์‹œ์—๋„ ๋ฐ์ดํ„ฐ๊ฐ€ ๋ฐ˜ํ™˜๋˜์–ด์•ผ ํ•จ"), + () -> assertEquals("์žฅ์• ๋Œ€์‘์ƒํ’ˆ", result.getContent().get(0).getName()) + ); + } + } + + @Nested + @DisplayName("๐Ÿ” ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์บ์‹œ ๊ฒ€์ฆ") + class CacheDetail { + + @Test + @DisplayName("์ƒ์„ธ ์กฐํšŒ ์‹œ ์บ์‹œ๊ฐ€ ์ €์žฅ๋˜๊ณ , DB ์‚ญ์ œ ํ›„์—๋„ ์กฐํšŒ๋œ๋‹ค") + void return_cachedProduct_whenDbDataDeleted() { + // arrange + Long brandId = 1L; + Product product = new Product(brandId, "์ƒ์„ธ๋ณด๊ธฐ ์ƒํ’ˆ", "์„ค๋ช…", new Money(5000L), 10); + Product savedProduct = productRepository.save(product); + Long productId = savedProduct.getId(); + + String expectedKey = "product:detail:" + productId; + + // act 1: ์ฒซ ๋ฒˆ์งธ ์กฐํšŒ (Cache Miss -> DB ์กฐํšŒ -> Redis ์ €์žฅ) + productService.getProduct(productId); + + // assert 1: Redis์— ํ‚ค๊ฐ€ ์ƒ์„ฑ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ + assertTrue(redisTemplate.hasKey(expectedKey), "์ƒ์„ธ ์กฐํšŒ ํ›„ Redis ํ‚ค๊ฐ€ ์ƒ์„ฑ๋˜์–ด์•ผ ํ•จ"); + + // act 2: DB ๋ฐ์ดํ„ฐ ๊ฐ•์ œ ์‚ญ์ œ + productJpaRepository.deleteAll(); + + // act 3: ๋‘ ๋ฒˆ์งธ ์กฐํšŒ (Cache Hit -> Redis ์กฐํšŒ) + Product result = productService.getProduct(productId); + + // assert 2: DB๋Š” ๋น„์—ˆ์ง€๋งŒ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์™€์•ผ ํ•จ + assertAll("์ƒ์„ธ ์กฐํšŒ ์บ์‹œ ๊ฒ€์ฆ", + () -> assertEquals(savedProduct.getId(), result.getId(), "ID๊ฐ€ ์ผ์น˜ํ•ด์•ผ ํ•จ"), + () -> assertEquals("์ƒ์„ธ๋ณด๊ธฐ ์ƒํ’ˆ", result.getName(), "์บ์‹œ๋œ ์ƒํ’ˆ๋ช… ์ผ์น˜ ํ™•์ธ") + ); + } + } +} From e8748581fea91e29ec657084926c4bfc3c16d284 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 11:12:04 +0900 Subject: [PATCH 112/164] =?UTF-8?q?fix:PageImpl=EC=9D=80=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=EA=B0=80=20=EC=97=86=EC=96=B4=EC=84=9C=20Jac?= =?UTF-8?q?kson=EC=9D=B4=20=EC=9D=BD=EC=96=B4=EC=98=A4=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20Pag?= =?UTF-8?q?eWrapper=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/support/page/PageWrapper.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/page/PageWrapper.java diff --git a/apps/commerce-api/src/main/java/com/loopers/support/page/PageWrapper.java b/apps/commerce-api/src/main/java/com/loopers/support/page/PageWrapper.java new file mode 100644 index 000000000..218455f3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/page/PageWrapper.java @@ -0,0 +1,31 @@ +package com.loopers.support.page; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +public class PageWrapper { + private List content; + private long totalElements; + private int pageNumber; + private int pageSize; + + public PageWrapper() {} + + public PageWrapper(Page page) { + this.content = page.getContent(); + this.totalElements = page.getTotalElements(); + this.pageNumber = page.getNumber(); + this.pageSize = page.getSize(); + } + + public Page toPage() { + return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements); + } + + public List getContent() { return content; } + public long getTotalElements() { return totalElements; } + public int getPageNumber() { return pageNumber; } + public int getPageSize() { return pageSize; } +} From b849cf7582c90d3813fa44ee8d7da0de87b5e2c2 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 11:48:23 +0900 Subject: [PATCH 113/164] =?UTF-8?q?refactor:=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20transactionTemplate=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/application/like/LikeFacade.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index b51998622..560df1cd0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -23,7 +23,6 @@ public class LikeFacade { public LikeInfo like(long userId, long productId) { Optional existingLike = likeService.findLike(userId, productId); - if (existingLike.isPresent()) { Product product = productService.getProduct(productId); return LikeInfo.from(existingLike.get(), product.getLikeCount()); @@ -31,11 +30,12 @@ public LikeInfo like(long userId, long productId) { for (int i = 0; i < RETRY_COUNT; i++) { try { + return transactionTemplate.execute(status -> { + Like newLike = likeService.save(userId, productId); + int updatedLikeCount = productService.increaseLikeCount(productId); + return LikeInfo.from(newLike, updatedLikeCount); + }); - Like newLike = likeService.save(userId, productId); - int updatedLikeCount = productService.increaseLikeCount(productId); - - return LikeInfo.from(newLike, updatedLikeCount); } catch (ObjectOptimisticLockingFailureException e) { if (i == RETRY_COUNT - 1) { throw e; @@ -43,7 +43,6 @@ public LikeInfo like(long userId, long productId) { sleep(50); } } - throw new IllegalStateException("์ข‹์•„์š” ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); } From 7367c45eef28aa3d6cbb4962d5dea3cc7c9ae4ef Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 28 Nov 2025 16:13:16 +0900 Subject: [PATCH 114/164] =?UTF-8?q?docs:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EC=A0=84=ED=9B=84=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/week5/DB_Index_Benchmark_Report.md | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/week5/DB_Index_Benchmark_Report.md diff --git a/docs/week5/DB_Index_Benchmark_Report.md b/docs/week5/DB_Index_Benchmark_Report.md new file mode 100644 index 000000000..96a7c8de8 --- /dev/null +++ b/docs/week5/DB_Index_Benchmark_Report.md @@ -0,0 +1,46 @@ +# ์ธ๋ฑ์Šค ๊ตฌ์กฐ ๋ณ€๊ฒฝ(๋‹จ์ผโ†’๋ณตํ•ฉ)์— ๋”ฐ๋ฅธ ์ฟผ๋ฆฌ ๋น„์šฉ ๊ฐœ์„  ๋ถ„์„ + +## 1. ๊ฐœ์š” ๋ฐ ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ + +์ด์ปค๋จธ์Šค ์„œ๋น„์Šค์˜ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์ธ **'ํŠน์ • ๋ธŒ๋žœ๋“œ์˜ ์ธ๊ธฐ ์ƒํ’ˆ ์กฐํšŒ'** API์˜ ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•˜๊ธฐ ์œ„ํ•ด ์ธ๋ฑ์Šค ์„ค๊ณ„๋ฅผ ์ง„ํ–‰ํ•˜๊ณ  ์„ฑ๋Šฅ ๋ณ€ํ™”๋ฅผ ์ธก์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. + +### 1.1 ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ + +- **Total Rows:** 100,000๊ฑด (Product ํ…Œ์ด๋ธ”) +- **Brands:** 30๊ฐœ (๊ท ๋“ฑ ๋ถ„ํฌ ๊ฐ€์ •) +- **DBMS:** MySQL 8.0 (InnoDB) + +### 1.2 ํ…Œ์ŠคํŠธ ๋Œ€์ƒ ์ฟผ๋ฆฌ + +SQL + +`SELECT * +FROM product +WHERE brand_id = 5 +ORDER BY like_count DESC +LIMIT 20;` + +--- + +## 2. ์ธ๋ฑ์Šค ์ ์šฉ ์‹œ๋‚˜๋ฆฌ์˜ค ๋น„๊ต + +์„ฑ๋Šฅ ์ธก์ •์€ **1) ์ธ๋ฑ์Šค ์—†์Œ**, **2) ๋‹จ์ผ ์ธ๋ฑ์Šค ์ ์šฉ**, **3) ๋ณตํ•ฉ ์ธ๋ฑ์Šค ์ ์šฉ** ์„ธ ๊ฐ€์ง€ ์ผ€์ด์Šค๋กœ ๋‚˜๋ˆ„์–ด ์ง„ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค. + +| **์ผ€์ด์Šค** | **์ ์šฉ ์ธ๋ฑ์Šค (Key)** | **๊ตฌ์„ฑ ์ปฌ๋Ÿผ** | +| --- | --- | --- | +| **Case 1** | (None) | ์—†์Œ (PK๋งŒ ์กด์žฌ) | +| **Case 2** | `idx_product_brand` | `(brand_id)` | +| **Case 3** | `idx_product_brand_like` | `(brand_id, like_count DESC)` | + +--- + +## 3. ์„ฑ๋Šฅ ์ธก์ • ๊ฒฐ๊ณผ (EXPLAIN ANALYZE) + +๊ฐ ์‹œ๋‚˜๋ฆฌ์˜ค๋ณ„ ์‹คํ–‰ ๊ณ„ํš ๋ฐ ์†Œ์š” ์‹œ๊ฐ„์„ ์š”์•ฝํ•œ ๊ฒฐ๊ณผํ‘œ์ž…๋‹ˆ๋‹ค. + +| **๊ตฌ๋ถ„** | **์ธ๋ฑ์Šค๋ช…** | **์‹คํ–‰ ์‹œ๊ฐ„ (Actual Time)** | **๋น„์šฉ (Cost)** | **์Šค์บ” ๋ฐฉ์‹ (Type/Extra)** | **๋น„๊ณ ** | +| --- | --- | --- | --- | --- | --- | +| **๊ฐœ์„  ์ „** | - | **56.3 ms** | 10,131 | `ALL` (Full Table Scan) | ์ „์ฒด ๋ฐ์ดํ„ฐ ์Šค์บ” ๋ฐ ์ •๋ ฌ ๋ฐœ์ƒ | +| **๊ฐœ์„  ํ›„** | `idx_product_brand` | **10.8 ms** (โ–ผ 80%) | 927 | `ref` (Using filesort) | **Best Performance** | +| **๋น„๊ต๊ตฐ** | `idx_product_brand_like` | 180.0 ms | 2,729 | `ref` (Index Lookup) | ์ •๋ ฌ์€ ์ƒ๋žตํ–ˆ์œผ๋‚˜ ๋žœ๋ค I/O ๋น„์šฉ ๋ฐœ์ƒ | + From 241da9d112bece810132b3b5de452ab698ed162d Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 2 Dec 2025 19:55:38 +0900 Subject: [PATCH 115/164] =?UTF-8?q?fix:=20=EB=B3=B4=EC=95=88=20=EC=9C=84?= =?UTF-8?q?=ED=97=98=EC=9C=BC=EB=A1=9C=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD(=EC=BD=94=EB=93=9C=EB=A0=88=EB=B9=97=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/config/redis/RedisConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java index dfec017a6..0a7b1305b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/redis/RedisConfig.java @@ -33,7 +33,8 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec objectMapper.activateDefaultTyping( BasicPolymorphicTypeValidator.builder() - .allowIfBaseType(Object.class) + .allowIfBaseType("com.loopers") + .allowIfBaseType("java.util") .build(), ObjectMapper.DefaultTyping.EVERYTHING ); From 6462c331d2e67f0264822719a11234e171a8377c Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 3 Dec 2025 11:04:09 +0900 Subject: [PATCH 116/164] =?UTF-8?q?add:=20=EA=B2=B0=EC=A0=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=AA=A8=EB=93=88=20=EB=B0=8F=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interfaces/api/OrderV1ApiE2ETest.java | 153 ++++++++++++++++++ apps/pg-simulator/README.md | 42 +++++ apps/pg-simulator/build.gradle.kts | 40 +++++ .../com/loopers/PaymentGatewayApplication.kt | 24 +++ .../loopers/application/payment/OrderInfo.kt | 14 ++ .../payment/PaymentApplicationService.kt | 88 ++++++++++ .../application/payment/PaymentCommand.kt | 22 +++ .../application/payment/TransactionInfo.kt | 39 +++++ .../com/loopers/config/web/WebMvcConfig.kt | 13 ++ .../com/loopers/domain/payment/CardType.kt | 7 + .../com/loopers/domain/payment/Payment.kt | 87 ++++++++++ .../loopers/domain/payment/PaymentEvent.kt | 28 ++++ .../domain/payment/PaymentEventPublisher.kt | 6 + .../loopers/domain/payment/PaymentRelay.kt | 7 + .../domain/payment/PaymentRepository.kt | 8 + .../domain/payment/TransactionKeyGenerator.kt | 20 +++ .../domain/payment/TransactionStatus.kt | 7 + .../com/loopers/domain/user/UserInfo.kt | 8 + .../payment/PaymentCoreEventPublisher.kt | 19 +++ .../payment/PaymentCoreRelay.kt | 21 +++ .../payment/PaymentCoreRepository.kt | 32 ++++ .../payment/PaymentJpaRepository.kt | 9 ++ .../interfaces/api/ApiControllerAdvice.kt | 119 ++++++++++++++ .../com/loopers/interfaces/api/ApiResponse.kt | 32 ++++ .../UserInfoArgumentResolver.kt | 32 ++++ .../interfaces/api/payment/PaymentApi.kt | 60 +++++++ .../interfaces/api/payment/PaymentDto.kt | 136 ++++++++++++++++ .../event/payment/PaymentEventListener.kt | 28 ++++ .../loopers/support/error/CoreException.kt | 6 + .../com/loopers/support/error/ErrorType.kt | 11 ++ .../src/main/resources/application.yml | 77 +++++++++ settings.gradle.kts | 1 + 32 files changed, 1196 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java create mode 100644 apps/pg-simulator/README.md create mode 100644 apps/pg-simulator/build.gradle.kts create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt create mode 100644 apps/pg-simulator/src/main/resources/application.yml diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..7c9a389e7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,153 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.User.Gender; +import com.loopers.domain.user.UserRepository; +import com.loopers.interfaces.api.user.UserV1Dto.SignUpRequest; +import com.loopers.interfaces.api.user.UserV1Dto.UserResponse; +import com.loopers.utils.DatabaseCleanUp; +import java.util.function.Function; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String ENDPOINT_POST = "/api/v1/users"; + private static final Function ENDPOINT_GET = userId -> "/api/v1/users/" + userId; + + private final TestRestTemplate testRestTemplate; + private final UserRepository userRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public OrderV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserRepository userRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userRepository = userRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() throws InterruptedException { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("POST /api/v1/users") + @Nested + class Post { + + @DisplayName("ํšŒ์› ๊ฐ€์ž…์ด ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ์ƒ์„ฑ๋œ ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsCreatedUserInfo_whenValidRequestIsProvided() { + // arrange + SignUpRequest request = new SignUpRequest( + "validId10", "valid@email.com", "2025-10-28", Gender.FEMALE); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_POST, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + + () -> assertThat(response.getBody().data().userId()).isEqualTo(request.userId()), + () -> assertThat(response.getBody().data().email()).isEqualTo(request.email()) + ); + } + + @DisplayName("ํšŒ์› ๊ฐ€์ž… ์‹œ์— ์„ฑ๋ณ„์ด ์—†์„ ๊ฒฝ์šฐ, 400 Bad Request ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsBadRequest_whenGenderIsNull() { + // arrange + SignUpRequest request = new SignUpRequest( + "validId10", "valid@email.com", "2025-10-28", null); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = + testRestTemplate.exchange( + ENDPOINT_POST, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("Get /api/v1/users/{userId}") + @Nested + class Get { + + @DisplayName("๋‚ด ์ •๋ณด ์กฐํšŒ์— ์„ฑ๊ณตํ•  ๊ฒฝ์šฐ, ํ•ด๋‹นํ•˜๋Š” ์œ ์ € ์ •๋ณด๋ฅผ ์‘๋‹ต์œผ๋กœ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsUserResponse_whenValidIdIsProvided() { + // arrange + User user = userRepository.save( + new User("validId10", "valid@email.com", "2025-10-28", Gender.FEMALE) + ); + String requestUrl = ENDPOINT_GET.apply(user.getUserId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = + testRestTemplate.exchange( + requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), + () -> assertThat(response.getBody().data().email()).isEqualTo(user.getEmail()) + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ID ๋กœ ์กฐํšŒํ•  ๊ฒฝ์šฐ, 404 Not Found ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.") + @Test + void returnsNotFound_whenInvalidIdIsProvided() { + // arrange + String invalidUserId = "non-existent-user-id"; + String requestUrl = ENDPOINT_GET.apply(invalidUserId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + + ResponseEntity> response = + testRestTemplate.exchange( + requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md new file mode 100644 index 000000000..118642638 --- /dev/null +++ b/apps/pg-simulator/README.md @@ -0,0 +1,42 @@ +## PG-Simulator (PaymentGateway) + +### Description +Loopback BE ๊ณผ์ •์„ ์œ„ํ•ด PaymentGateway ๋ฅผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๋Š” App Module ์ž…๋‹ˆ๋‹ค. +`local` ํ”„๋กœํ•„๋กœ ์‹คํ–‰ ๊ถŒ์žฅํ•˜๋ฉฐ, ์ปค๋จธ์Šค ์„œ๋น„์Šค์™€์˜ ๋™์‹œ ์‹คํ–‰์„ ์œ„ํ•ด ์„œ๋ฒ„ ํฌํŠธ๊ฐ€ ์กฐ์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. +- server port : 8082 +- actuator port : 8083 + +### Getting Started +๋ถ€ํŠธ ์„œ๋ฒ„๋ฅผ ์•„๋ž˜ ๋ช…๋ น์–ด ํ˜น์€ `intelliJ` ํ†ตํ•ด ์‹คํ–‰ํ•ด์ฃผ์„ธ์š”. +```shell +./gradlew :apps:pg-simulator:bootRun +``` + +API ๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด ์ฃผ์–ด์ง€๋‹ˆ, ์ปค๋จธ์Šค ์„œ๋น„์Šค์™€ ๋™์‹œ์— ์‹คํ–‰์‹œํ‚จ ํ›„ ์ง„ํ–‰ํ•ด์ฃผ์‹œ๋ฉด ๋ฉ๋‹ˆ๋‹ค. +- ๊ฒฐ์ œ ์š”์ฒญ API +- ๊ฒฐ์ œ ์ •๋ณด ํ™•์ธ `by transactionKey` +- ๊ฒฐ์ œ ์ •๋ณด ๋ชฉ๋ก ์กฐํšŒ `by orderId` + +```http request +### ๊ฒฐ์ œ ์š”์ฒญ +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### ๊ฒฐ์ œ ์ •๋ณด ํ™•์ธ +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### ์ฃผ๋ฌธ์— ์—ฎ์ธ ๊ฒฐ์ œ ์ •๋ณด ์กฐํšŒ +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 + +``` \ No newline at end of file diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts new file mode 100644 index 000000000..653d549da --- /dev/null +++ b/apps/pg-simulator/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + val kotlinVersion = "2.0.20" + + id("org.jetbrains.kotlin.jvm") version(kotlinVersion) + id("org.jetbrains.kotlin.kapt") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion) + id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion) +} + +kotlin { + compilerOptions { + jvmToolchain(21) + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + // add-ons + implementation(project(":modules:jpa")) + implementation(project(":modules:redis")) + implementation(project(":supports:jackson")) + implementation(project(":supports:logging")) + implementation(project(":supports:monitoring")) + + // kotlin + implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // web + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + + // querydsl + kapt("com.querydsl:querydsl-apt::jakarta") + + // test-fixtures + testImplementation(testFixtures(project(":modules:jpa"))) + testImplementation(testFixtures(project(":modules:redis"))) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt new file mode 100644 index 000000000..05595d135 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt @@ -0,0 +1,24 @@ +package com.loopers + +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.ConfigurationPropertiesScan +import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync +import java.util.TimeZone + +@ConfigurationPropertiesScan +@EnableAsync +@SpringBootApplication +class PaymentGatewayApplication { + + @PostConstruct + fun started() { + // set timezone + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + } +} + +fun main(args: Array) { + runApplication(*args) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt new file mode 100644 index 000000000..7e04d1ce0 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt @@ -0,0 +1,14 @@ +package com.loopers.application.payment + +/** + * ๊ฒฐ์ œ ์ฃผ๋ฌธ ์ •๋ณด + * + * ๊ฒฐ์ œ๋Š” ์ฃผ๋ฌธ์— ๋Œ€ํ•œ ๋‹ค์ˆ˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๊ตฌ์„ฑ๋ฉ๋‹ˆ๋‹ค. + * + * @property orderId ์ฃผ๋ฌธ ์ •๋ณด + * @property transactions ์ฃผ๋ฌธ์— ์—ฎ์ธ ํŠธ๋žœ์žญ์…˜ ๋ชฉ๋ก + */ +data class OrderInfo( + val orderId: String, + val transactions: List, +) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt new file mode 100644 index 000000000..9a5ebdc5d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt @@ -0,0 +1,88 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import com.loopers.domain.payment.PaymentRelay +import com.loopers.domain.payment.PaymentRepository +import com.loopers.domain.payment.TransactionKeyGenerator +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class PaymentApplicationService( + private val paymentRepository: PaymentRepository, + private val paymentEventPublisher: PaymentEventPublisher, + private val paymentRelay: PaymentRelay, + private val transactionKeyGenerator: TransactionKeyGenerator, +) { + companion object { + private val RATE_LIMIT_EXCEEDED = (1..20) + private val RATE_INVALID_CARD = (21..30) + } + + @Transactional + fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo { + command.validate() + + val transactionKey = transactionKeyGenerator.generate() + val payment = paymentRepository.save( + Payment( + transactionKey = transactionKey, + userId = command.userId, + orderId = command.orderId, + cardType = command.cardType, + cardNo = command.cardNo, + amount = command.amount, + callbackUrl = command.callbackUrl, + ), + ) + + paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment)) + + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo { + val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) ๊ฒฐ์ œ๊ฑด์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + return TransactionInfo.from(payment) + } + + @Transactional(readOnly = true) + fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo { + val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId) + if (payments.isEmpty()) { + throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) ์— ํ•ด๋‹นํ•˜๋Š” ๊ฒฐ์ œ๊ฑด์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + } + + return OrderInfo( + orderId = orderId, + transactions = payments.map { TransactionInfo.from(it) }, + ) + } + + @Transactional + fun handle(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) ๊ฒฐ์ œ๊ฑด์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + + val rate = (1..100).random() + when (rate) { + in RATE_LIMIT_EXCEEDED -> payment.limitExceeded() + in RATE_INVALID_CARD -> payment.invalidCard() + else -> payment.approve() + } + paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment)) + } + + fun notifyTransactionResult(transactionKey: String) { + val payment = paymentRepository.findByTransactionKey(transactionKey) + ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) ๊ฒฐ์ œ๊ฑด์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment)) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt new file mode 100644 index 000000000..01d8ae440 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt @@ -0,0 +1,22 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentCommand { + data class CreateTransaction( + val userId: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + fun validate() { + if (amount <= 0L) { + throw CoreException(ErrorType.BAD_REQUEST, "์š”์ฒญ ๊ธˆ์•ก์€ 0 ๋ณด๋‹ค ํฐ ์ •์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt new file mode 100644 index 000000000..5c21e51af --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt @@ -0,0 +1,39 @@ +package com.loopers.application.payment + +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.TransactionStatus + +/** + * ํŠธ๋žœ์žญ์…˜ ์ •๋ณด + * + * @property transactionKey ํŠธ๋žœ์žญ์…˜ KEY + * @property orderId ์ฃผ๋ฌธ ID + * @property cardType ์นด๋“œ ์ข…๋ฅ˜ + * @property cardNo ์นด๋“œ ๋ฒˆํ˜ธ + * @property amount ๊ธˆ์•ก + * @property status ์ฒ˜๋ฆฌ ์ƒํƒœ + * @property reason ์ฒ˜๋ฆฌ ์‚ฌ์œ  + */ +data class TransactionInfo( + val transactionKey: String, + val orderId: String, + val cardType: CardType, + val cardNo: String, + val amount: Long, + val status: TransactionStatus, + val reason: String?, +) { + companion object { + fun from(payment: Payment): TransactionInfo = + TransactionInfo( + transactionKey = payment.transactionKey, + orderId = payment.orderId, + cardType = payment.cardType, + cardNo = payment.cardNo, + amount = payment.amount, + status = payment.status, + reason = payment.reason, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt new file mode 100644 index 000000000..8aec9dc82 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt @@ -0,0 +1,13 @@ +package com.loopers.config.web + +import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver +import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +class WebMvcConfig : WebMvcConfigurer { + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add(UserInfoArgumentResolver()) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt new file mode 100644 index 000000000..55008a95d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class CardType { + SAMSUNG, + KB, + HYUNDAI, +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt new file mode 100644 index 000000000..cfc2386c1 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt @@ -0,0 +1,87 @@ +package com.loopers.domain.payment + +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Id +import jakarta.persistence.Index +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table( + name = "payments", + indexes = [ + Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"), + Index(name = "idx_user_order", columnList = "user_id, order_id"), + Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true), + ] +) +class Payment( + @Id + @Column(name = "transaction_key", nullable = false, unique = true) + val transactionKey: String, + + @Column(name = "user_id", nullable = false) + val userId: String, + + @Column(name = "order_id", nullable = false) + val orderId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + val cardType: CardType, + + @Column(name = "card_no", nullable = false) + val cardNo: String, + + @Column(name = "amount", nullable = false) + val amount: Long, + + @Column(name = "callback_url", nullable = false) + val callbackUrl: String, +) { + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: TransactionStatus = TransactionStatus.PENDING + private set + + @Column(name = "reason", nullable = true) + var reason: String? = null + private set + + @Column(name = "created_at", nullable = false) + var createdAt: LocalDateTime = LocalDateTime.now() + private set + + @Column(name = "updated_at", nullable = false) + var updatedAt: LocalDateTime = LocalDateTime.now() + private set + + fun approve() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "๊ฒฐ์ œ์Šน์ธ์€ ๋Œ€๊ธฐ์ƒํƒœ์—์„œ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + } + status = TransactionStatus.SUCCESS + reason = "์ •์ƒ ์Šน์ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + } + + fun invalidCard() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "๊ฒฐ์ œ์ฒ˜๋ฆฌ๋Š” ๋Œ€๊ธฐ์ƒํƒœ์—์„œ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + } + status = TransactionStatus.FAILED + reason = "์ž˜๋ชป๋œ ์นด๋“œ์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์นด๋“œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”." + } + + fun limitExceeded() { + if (status != TransactionStatus.PENDING) { + throw CoreException(ErrorType.INTERNAL_ERROR, "ํ•œ๋„์ดˆ๊ณผ ์ฒ˜๋ฆฌ๋Š” ๋Œ€๊ธฐ์ƒํƒœ์—์„œ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") + } + status = TransactionStatus.FAILED + reason = "ํ•œ๋„์ดˆ๊ณผ์ž…๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์นด๋“œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”." + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt new file mode 100644 index 000000000..8e495b2e3 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt @@ -0,0 +1,28 @@ +package com.loopers.domain.payment + +object PaymentEvent { + data class PaymentCreated( + val transactionKey: String, + ) { + companion object { + fun from(payment: Payment): PaymentCreated = PaymentCreated(transactionKey = payment.transactionKey) + } + } + + data class PaymentHandled( + val transactionKey: String, + val status: TransactionStatus, + val reason: String?, + val callbackUrl: String, + ) { + companion object { + fun from(payment: Payment): PaymentHandled = + PaymentHandled( + transactionKey = payment.transactionKey, + status = payment.status, + reason = payment.reason, + callbackUrl = payment.callbackUrl, + ) + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt new file mode 100644 index 000000000..251c68319 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt @@ -0,0 +1,6 @@ +package com.loopers.domain.payment + +interface PaymentEventPublisher { + fun publish(event: PaymentEvent.PaymentCreated) + fun publish(event: PaymentEvent.PaymentHandled) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt new file mode 100644 index 000000000..e622899b2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +import com.loopers.application.payment.TransactionInfo + +interface PaymentRelay { + fun notify(callbackUrl: String, transactionInfo: TransactionInfo) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt new file mode 100644 index 000000000..c1173c0aa --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.payment + +interface PaymentRepository { + fun save(payment: Payment): Payment + fun findByTransactionKey(transactionKey: String): Payment? + fun findByTransactionKey(userId: String, transactionKey: String): Payment? + fun findByOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt new file mode 100644 index 000000000..c8703a763 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt @@ -0,0 +1,20 @@ +package com.loopers.domain.payment + +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Component +class TransactionKeyGenerator { + companion object { + private const val KEY_TRANSACTION = "TR" + private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd") + } + + fun generate(): String { + val now = LocalDateTime.now() + val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6) + return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid" + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt new file mode 100644 index 000000000..0c94bcfb9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt @@ -0,0 +1,7 @@ +package com.loopers.domain.payment + +enum class TransactionStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt new file mode 100644 index 000000000..c51e660a9 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt @@ -0,0 +1,8 @@ +package com.loopers.domain.user + +/** + * user ์ •๋ณด + * + * @param userId ์œ ์ € ์‹๋ณ„์ž + */ +data class UserInfo(val userId: String) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt new file mode 100644 index 000000000..715516360 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.PaymentEvent +import com.loopers.domain.payment.PaymentEventPublisher +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class PaymentCoreEventPublisher( + private val applicationEventPublisher: ApplicationEventPublisher, +) : PaymentEventPublisher { + override fun publish(event: PaymentEvent.PaymentCreated) { + applicationEventPublisher.publishEvent(event) + } + + override fun publish(event: PaymentEvent.PaymentHandled) { + applicationEventPublisher.publishEvent(event) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt new file mode 100644 index 000000000..ffd643c0f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt @@ -0,0 +1,21 @@ +package com.loopers.infrastructure.payment + +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.PaymentRelay +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate + +@Component +class PaymentCoreRelay : PaymentRelay { + companion object { + private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java) + private val restTemplate = RestTemplate() + } + + override fun notify(callbackUrl: String, transactionInfo: TransactionInfo) { + runCatching { + restTemplate.postForEntity(callbackUrl, transactionInfo, Any::class.java) + }.onFailure { e -> logger.error("์ฝœ๋ฐฑ ํ˜ธ์ถœ์„ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. {}", e.message, e) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt new file mode 100644 index 000000000..cf521c47d --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import com.loopers.domain.payment.PaymentRepository +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import kotlin.jvm.optionals.getOrNull + +@Component +class PaymentCoreRepository( + private val paymentJpaRepository: PaymentJpaRepository, +) : PaymentRepository { + @Transactional + override fun save(payment: Payment): Payment { + return paymentJpaRepository.save(payment) + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(transactionKey: String): Payment? { + return paymentJpaRepository.findById(transactionKey).getOrNull() + } + + @Transactional(readOnly = true) + override fun findByTransactionKey(userId: String, transactionKey: String): Payment? { + return paymentJpaRepository.findByUserIdAndTransactionKey(userId, transactionKey) + } + + override fun findByOrderId(userId: String, orderId: String): List { + return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId) + .sortedByDescending { it.updatedAt } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt new file mode 100644 index 000000000..a5ea32822 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.payment + +import com.loopers.domain.payment.Payment +import org.springframework.data.jpa.repository.JpaRepository + +interface PaymentJpaRepository : JpaRepository { + fun findByUserIdAndTransactionKey(userId: String, transactionKey: String): Payment? + fun findByUserIdAndOrderId(userId: String, orderId: String): List +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt new file mode 100644 index 000000000..434a229e2 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt @@ -0,0 +1,119 @@ +package com.loopers.interfaces.api + +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.exc.InvalidFormatException +import com.fasterxml.jackson.databind.exc.MismatchedInputException +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException +import org.springframework.web.server.ServerWebInputException +import org.springframework.web.servlet.resource.NoResourceFoundException +import kotlin.collections.joinToString +import kotlin.jvm.java +import kotlin.text.isNotEmpty +import kotlin.text.toRegex + +@RestControllerAdvice +class ApiControllerAdvice { + private val log = LoggerFactory.getLogger(ApiControllerAdvice::class.java) + + @ExceptionHandler + fun handle(e: CoreException): ResponseEntity> { + log.warn("CoreException : {}", e.customMessage ?: e.message, e) + return failureResponse(errorType = e.errorType, errorMessage = e.customMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: MethodArgumentTypeMismatchException): ResponseEntity> { + val name = e.name + val type = e.requiredType?.simpleName ?: "unknown" + val value = e.value ?: "null" + val message = "์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ '$name' (ํƒ€์ž…: $type)์˜ ๊ฐ’ '$value'์ด(๊ฐ€) ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: MissingServletRequestParameterException): ResponseEntity> { + val name = e.parameterName + val type = e.parameterType + val message = "ํ•„์ˆ˜ ์š”์ฒญ ํŒŒ๋ผ๋ฏธํ„ฐ '$name' (ํƒ€์ž…: $type)๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message) + } + + @ExceptionHandler + fun handleBadRequest(e: HttpMessageNotReadableException): ResponseEntity> { + val errorMessage = when (val rootCause = e.rootCause) { + is InvalidFormatException -> { + val fieldName = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + + val valueIndicationMessage = when { + rootCause.targetType.isEnum -> { + val enumClass = rootCause.targetType + val enumValues = enumClass.enumConstants.joinToString(", ") { it.toString() } + "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฐ’ : [$enumValues]" + } + + else -> "" + } + + val expectedType = rootCause.targetType.simpleName + val value = rootCause.value + + "ํ•„๋“œ '$fieldName'์˜ ๊ฐ’ '$value'์ด(๊ฐ€) ์˜ˆ์ƒ ํƒ€์ž…($expectedType)๊ณผ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. $valueIndicationMessage" + } + + is MismatchedInputException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "ํ•„์ˆ˜ ํ•„๋“œ '$fieldPath'์ด(๊ฐ€) ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค." + } + + is JsonMappingException -> { + val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" } + "ํ•„๋“œ '$fieldPath'์—์„œ JSON ๋งคํ•‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: ${rootCause.originalMessage}" + } + + else -> "์š”์ฒญ ๋ณธ๋ฌธ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. JSON ๋ฉ”์„ธ์ง€ ๊ทœ๊ฒฉ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”." + } + + return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = errorMessage) + } + + @ExceptionHandler + fun handleBadRequest(e: ServerWebInputException): ResponseEntity> { + fun extractMissingParameter(message: String): String { + val regex = "'(.+?)'".toRegex() + return regex.find(message)?.groupValues?.get(1) ?: "" + } + + val missingParams = extractMissingParameter(e.reason ?: "") + return if (missingParams.isNotEmpty()) { + failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "ํ•„์ˆ˜ ์š”์ฒญ ๊ฐ’ \'$missingParams\'๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.") + } else { + failureResponse(errorType = ErrorType.BAD_REQUEST) + } + } + + @ExceptionHandler + fun handleNotFound(e: NoResourceFoundException): ResponseEntity> { + return failureResponse(errorType = ErrorType.NOT_FOUND) + } + + @ExceptionHandler + fun handle(e: Throwable): ResponseEntity> { + log.error("Exception : {}", e.message, e) + val errorType = ErrorType.INTERNAL_ERROR + return failureResponse(errorType = errorType) + } + + private fun failureResponse(errorType: ErrorType, errorMessage: String? = null): ResponseEntity> = + ResponseEntity( + ApiResponse.fail(errorCode = errorType.code, errorMessage = errorMessage ?: errorType.message), + errorType.status, + ) +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt new file mode 100644 index 000000000..f5c38ab5e --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api + +data class ApiResponse( + val meta: Metadata, + val data: T?, +) { + data class Metadata( + val result: Result, + val errorCode: String?, + val message: String?, + ) { + enum class Result { SUCCESS, FAIL } + + companion object { + fun success() = Metadata(Result.SUCCESS, null, null) + + fun fail(errorCode: String, errorMessage: String) = Metadata(Result.FAIL, errorCode, errorMessage) + } + } + + companion object { + fun success(): ApiResponse = ApiResponse(Metadata.success(), null) + + fun success(data: T? = null) = ApiResponse(Metadata.success(), data) + + fun fail(errorCode: String, errorMessage: String): ApiResponse = + ApiResponse( + meta = Metadata.fail(errorCode = errorCode, errorMessage = errorMessage), + data = null, + ) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt new file mode 100644 index 000000000..9ef6c25da --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.argumentresolver + +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class UserInfoArgumentResolver: HandlerMethodArgumentResolver { + companion object { + private const val KEY_USER_ID = "X-USER-ID" + } + + override fun supportsParameter(parameter: MethodParameter): Boolean { + return UserInfo::class.java.isAssignableFrom(parameter.parameterType) + } + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): UserInfo { + val userId = webRequest.getHeader(KEY_USER_ID) + ?: throw CoreException(ErrorType.BAD_REQUEST, "์œ ์ € ID ํ—ค๋”๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + + return UserInfo(userId) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt new file mode 100644 index 000000000..22d5cbe38 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.interfaces.api.ApiResponse +import com.loopers.domain.user.UserInfo +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType +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.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v1/payments") +class PaymentApi( + private val paymentApplicationService: PaymentApplicationService, +) { + @PostMapping + fun request( + userInfo: UserInfo, + @RequestBody request: PaymentDto.PaymentRequest, + ): ApiResponse { + request.validate() + + // 100ms ~ 500ms ์ง€์—ฐ + Thread.sleep((100..500L).random()) + + // 40% ํ™•๋ฅ ๋กœ ์š”์ฒญ ์‹คํŒจ + if ((1..100).random() <= 40) { + throw CoreException(ErrorType.INTERNAL_ERROR, "ํ˜„์žฌ ์„œ๋ฒ„๊ฐ€ ๋ถˆ์•ˆ์ •ํ•ฉ๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.") + } + + return paymentApplicationService.createTransaction(request.toCommand(userInfo.userId)) + .let { PaymentDto.TransactionResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping("/{transactionKey}") + fun getTransaction( + userInfo: UserInfo, + @PathVariable("transactionKey") transactionKey: String, + ): ApiResponse { + return paymentApplicationService.getTransactionDetailInfo(userInfo, transactionKey) + .let { PaymentDto.TransactionDetailResponse.from(it) } + .let { ApiResponse.success(it) } + } + + @GetMapping + fun getTransactionsByOrder( + userInfo: UserInfo, + @RequestParam("orderId", required = false) orderId: String, + ): ApiResponse { + return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId) + .let { PaymentDto.OrderResponse.from(it) } + .let { ApiResponse.success(it) } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt new file mode 100644 index 000000000..52a00b156 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt @@ -0,0 +1,136 @@ +package com.loopers.interfaces.api.payment + +import com.loopers.application.payment.OrderInfo +import com.loopers.application.payment.PaymentCommand +import com.loopers.application.payment.TransactionInfo +import com.loopers.domain.payment.CardType +import com.loopers.domain.payment.TransactionStatus +import com.loopers.support.error.CoreException +import com.loopers.support.error.ErrorType + +object PaymentDto { + data class PaymentRequest( + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val callbackUrl: String, + ) { + companion object { + private val REGEX_CARD_NO = Regex("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$") + private const val PREFIX_CALLBACK_URL = "http://localhost:8080" + } + + fun validate() { + if (orderId.isBlank() || orderId.length < 6) { + throw CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ID๋Š” 6์ž๋ฆฌ ์ด์ƒ ๋ฌธ์ž์—ด์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + if (!REGEX_CARD_NO.matches(cardNo)) { + throw CoreException(ErrorType.BAD_REQUEST, "์นด๋“œ ๋ฒˆํ˜ธ๋Š” xxxx-xxxx-xxxx-xxxx ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + if (amount <= 0) { + throw CoreException(ErrorType.BAD_REQUEST, "๊ฒฐ์ œ๊ธˆ์•ก์€ ์–‘์˜ ์ •์ˆ˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + if (!callbackUrl.startsWith(PREFIX_CALLBACK_URL)) { + throw CoreException(ErrorType.BAD_REQUEST, "์ฝœ๋ฐฑ URL ์€ $PREFIX_CALLBACK_URL ๋กœ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.") + } + } + + fun toCommand(userId: String): PaymentCommand.CreateTransaction = + PaymentCommand.CreateTransaction( + userId = userId, + orderId = orderId, + cardType = cardType.toCardType(), + cardNo = cardNo, + amount = amount, + callbackUrl = callbackUrl, + ) + } + + data class TransactionDetailResponse( + val transactionKey: String, + val orderId: String, + val cardType: CardTypeDto, + val cardNo: String, + val amount: Long, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionDetailResponse = + TransactionDetailResponse( + transactionKey = transactionInfo.transactionKey, + orderId = transactionInfo.orderId, + cardType = CardTypeDto.from(transactionInfo.cardType), + cardNo = transactionInfo.cardNo, + amount = transactionInfo.amount, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class TransactionResponse( + val transactionKey: String, + val status: TransactionStatusResponse, + val reason: String?, + ) { + companion object { + fun from(transactionInfo: TransactionInfo): TransactionResponse = + TransactionResponse( + transactionKey = transactionInfo.transactionKey, + status = TransactionStatusResponse.from(transactionInfo.status), + reason = transactionInfo.reason, + ) + } + } + + data class OrderResponse( + val orderId: String, + val transactions: List, + ) { + companion object { + fun from(orderInfo: OrderInfo): OrderResponse = + OrderResponse( + orderId = orderInfo.orderId, + transactions = orderInfo.transactions.map { TransactionResponse.from(it) }, + ) + } + } + + enum class CardTypeDto { + SAMSUNG, + KB, + HYUNDAI, + ; + + fun toCardType(): CardType = when (this) { + SAMSUNG -> CardType.SAMSUNG + KB -> CardType.KB + HYUNDAI -> CardType.HYUNDAI + } + + companion object { + fun from(cardType: CardType) = when (cardType) { + CardType.SAMSUNG -> SAMSUNG + CardType.KB -> KB + CardType.HYUNDAI -> HYUNDAI + } + } + } + + enum class TransactionStatusResponse { + PENDING, + SUCCESS, + FAILED, + ; + + companion object { + fun from(transactionStatus: TransactionStatus) = when (transactionStatus) { + TransactionStatus.PENDING -> PENDING + TransactionStatus.SUCCESS -> SUCCESS + TransactionStatus.FAILED -> FAILED + } + } + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt new file mode 100644 index 000000000..241322890 --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt @@ -0,0 +1,28 @@ +package com.loopers.interfaces.event.payment + +import com.loopers.application.payment.PaymentApplicationService +import com.loopers.domain.payment.PaymentEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class PaymentEventListener( + private val paymentApplicationService: PaymentApplicationService, +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentCreated) { + val thresholdMillis = (1000L..5000L).random() + Thread.sleep(thresholdMillis) + + paymentApplicationService.handle(event.transactionKey) + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: PaymentEvent.PaymentHandled) { + paymentApplicationService.notifyTransactionResult(transactionKey = event.transactionKey) + } +} diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt new file mode 100644 index 000000000..120f7fc5f --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt @@ -0,0 +1,6 @@ +package com.loopers.support.error + +class CoreException( + val errorType: ErrorType, + val customMessage: String? = null, +) : RuntimeException(customMessage ?: errorType.message) diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt new file mode 100644 index 000000000..e0799a5ea --- /dev/null +++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt @@ -0,0 +1,11 @@ +package com.loopers.support.error + +import org.springframework.http.HttpStatus + +enum class ErrorType(val status: HttpStatus, val code: String, val message: String) { + /** ๋ฒ”์šฉ ์—๋Ÿฌ */ + INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase, "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.reasonPhrase, "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.reasonPhrase, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.reasonPhrase, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), +} diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml new file mode 100644 index 000000000..addf0e29c --- /dev/null +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -0,0 +1,77 @@ +server: + shutdown: graceful + tomcat: + threads: + max: 200 # ์ตœ๋Œ€ ์›Œ์ปค ์Šค๋ ˆ๋“œ ์ˆ˜ (default : 200) + min-spare: 10 # ์ตœ์†Œ ์œ ์ง€ ์Šค๋ ˆ๋“œ ์ˆ˜ (default : 10) + connection-timeout: 1m # ์—ฐ๊ฒฐ ํƒ€์ž„์•„์›ƒ (ms) (default : 60000ms = 1m) + max-connections: 8192 # ์ตœ๋Œ€ ๋™์‹œ ์—ฐ๊ฒฐ ์ˆ˜ (default : 8192) + accept-count: 100 # ๋Œ€๊ธฐ ํ ํฌ๊ธฐ (default : 100) + keep-alive-timeout: 60s # 60s + max-http-request-header-size: 8KB + +spring: + main: + web-application-type: servlet + application: + name: commerce-api + profiles: + active: local + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml + +datasource: + mysql-jpa: + main: + jdbc-url: jdbc:mysql://localhost:3306/paymentgateway + +springdoc: + use-fqn: true + swagger-ui: + path: /swagger-ui.html + +--- +spring: + config: + activate: + on-profile: local, test + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: dev + +server: + port: 8082 + +management: + server: + port: 8083 + +--- +spring: + config: + activate: + on-profile: qa + +--- +spring: + config: + activate: + on-profile: prd + +springdoc: + api-docs: + enabled: false \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a2c303835..40a9e0cc3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,7 @@ include( ":supports:jackson", ":supports:logging", ":supports:monitoring", + ":apps:pg-simulator", ) // configurations From c295ebb6a663c684bdc9fe22001fa7ddd22889ba Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 3 Dec 2025 14:24:20 +0900 Subject: [PATCH 117/164] =?UTF-8?q?add:=20http=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- http/http-client.env.json | 3 ++- http/pg-simulator/payments.http | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 http/pg-simulator/payments.http diff --git a/http/http-client.env.json b/http/http-client.env.json index 0db34e6a1..99e141b36 100644 --- a/http/http-client.env.json +++ b/http/http-client.env.json @@ -1,5 +1,6 @@ { "local": { - "commerce-api": "http://localhost:8080" + "commerce-api": "http://localhost:8080", + "pg-simulator": "http://localhost:8082" } } diff --git a/http/pg-simulator/payments.http b/http/pg-simulator/payments.http new file mode 100644 index 000000000..4081787bb --- /dev/null +++ b/http/pg-simulator/payments.http @@ -0,0 +1,20 @@ +### ๊ฒฐ์ œ ์š”์ฒญ +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: 135135 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### ๊ฒฐ์ œ ์ •๋ณด ํ™•์ธ +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: 135135 + +### ์ฃผ๋ฌธ์— ์—ฎ์ธ ๊ฒฐ์ œ ์ •๋ณด ์กฐํšŒ +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: 135135 \ No newline at end of file From 50d553e4b15d7334ae29b69a1123e990d16e759a Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 3 Dec 2025 18:11:00 +0900 Subject: [PATCH 118/164] =?UTF-8?q?chore:=20FeignClient,=20Resilience=20?= =?UTF-8?q?=EB=93=B1=ED=95=84=EC=9A=94=ED=95=9C=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=EB=93=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..fbfeee7e7 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -19,4 +19,22 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // Resilience4j (Spring Boot 3.x ๊ธฐ์ค€) + implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0") + + // AOP + implementation("org.springframework.boot:spring-boot-starter-aop") + + // actuator + implementation("org.springframework.boot:spring-boot-starter-actuator") + + //Micrometer Prometheus + implementation("io.micrometer:micrometer-registry-prometheus") + + //Spring Cloud OpenFeign + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + + //Spring Cloud CircuitBreaker + implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j") } From abfd777995539446430057ffb2af3a7ba5774f89 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 09:06:47 +0900 Subject: [PATCH 119/164] =?UTF-8?q?chore:=20Resilience4j=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20(Retry,=20CircuitBreaker=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..8b5957e23 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -24,6 +24,31 @@ spring: - logging.yml - monitoring.yml +resilience4j: + # Retry + retry: + instances: + pgRetry: + max-attempts: 3 + wait-duration: 1000ms + retry-exceptions: + - java.io.IOException + - java.util.concurrent.TimeoutException + - java.net.ConnectException + + # CircuitBreaker + circuitbreaker: + configs: + default: + register-health-indicator: true + instances: + simpleCircuitBreakerConfig: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + springdoc: use-fqn: true swagger-ui: From 88d484f248f98dee6175935ce6c4b08800d6124d Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 09:34:11 +0900 Subject: [PATCH 120/164] =?UTF-8?q?feature:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(Pa?= =?UTF-8?q?yment=20=EC=97=94=ED=8B=B0=ED=8B=B0,=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4,=20=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EB=93=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/payment/Payment.java | 80 +++++++++++++++++++ .../domain/payment/PaymentRepository.java | 6 ++ .../domain/payment/PaymentService.java | 27 +++++++ .../loopers/domain/payment/PaymentStatus.java | 8 ++ .../payment/PaymentJpaRepository.java | 8 ++ .../payment/PaymentRepositoryImpl.java | 18 +++++ 6 files changed, 147 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java new file mode 100644 index 000000000..cbf8a4e6f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -0,0 +1,80 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.money.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +@Entity +@Table(name = "payment") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Payment extends BaseEntity { + + @Column(name = "ref_user_id", nullable = false) + private Long userId; + + @Column(name = "ref_order_id", nullable = false) + private Long orderId; + + @Column(name = "transaction_id", nullable = false, unique = true) + private String transactionId;//๋ฉฑ๋“ฑํ‚ค + + @Column(name = "card_type", nullable = false) + private String cardType; + + @Column(name = "card_no", nullable = false) + private String cardNo; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "amount")) + private Money amount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + + @Column(name = "pg_txn_id") + private String pgTxnId; + + public Payment(Long orderId, Long userId, Money amount, String cardType, String cardNo) { + validateConstructor(orderId, userId, amount, cardType, cardNo); + + this.orderId = orderId; + this.userId = userId; + this.amount = amount; + this.cardType = cardType; + this.cardNo = cardNo; + this.status = PaymentStatus.READY; + + this.transactionId = UUID.randomUUID().toString(); + } + + private void validateConstructor(Long orderId, Long userId, Money amount, String cardType, String cardNo) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (amount == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฒฐ์ œ ๊ธˆ์•ก์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!StringUtils.hasText(cardType)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์นด๋“œ ์ข…๋ฅ˜๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (!StringUtils.hasText(cardNo)) { + throw new CoreException(ErrorType.BAD_REQUEST, "์นด๋“œ ๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 000000000..336bdd0ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.payment; + +public interface PaymentRepository { + + Payment save(Payment payment); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java new file mode 100644 index 000000000..fdc1a1227 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.order.Order; +import com.loopers.domain.user.User; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PaymentService { + + private final PaymentRepository paymentRepository; + + @Transactional + public Payment createPendingPayment(User user, Order order, String cardType, String cardNo) { + Payment payment = new Payment( + order.getId(), + user.getId(), + order.getTotalAmount(), + cardType, + cardNo + ); + + return paymentRepository.save(payment); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java new file mode 100644 index 000000000..7ed6737c1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.loopers.domain.payment; + +public enum PaymentStatus { + READY, + PAID, + CANCELLED, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 000000000..937e6760a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PaymentJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 000000000..5c49b39e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public Payment save(Payment payment) { + return paymentJpaRepository.save(payment); + } +} From d40f6d59b96c3a712a14e30dc452b4c62a5a7eec Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 15:35:46 +0900 Subject: [PATCH 121/164] =?UTF-8?q?feature:=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=ED=83=80=EC=9E=85=20PG=20simulator=EB=9E=91=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/payment/CardType.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java new file mode 100644 index 000000000..3983c7de5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.payment; + +public enum CardType { + SAMSUNG, + KB, + HYUNDAI +} From 201cf3b2f23e423968ff1477a98822d5b32df402 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 20:07:14 +0900 Subject: [PATCH 122/164] =?UTF-8?q?feature:=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(PG=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/CommerceApiApplication.java | 2 + .../application/order/OrderFacade.java | 15 ++- .../loopers/application/order/OrderInfo.java | 11 ++- .../loopers/domain/order/OrderCommand.java | 4 +- .../com/loopers/domain/payment/Payment.java | 24 +++-- .../domain/payment/PaymentExecutor.java | 6 ++ .../domain/payment/PaymentService.java | 18 +++- .../com/loopers/domain/product/Product.java | 8 +- .../infrastructure/pg/LoopersPgExecutor.java | 45 +++++++++ .../loopers/infrastructure/pg/PgClient.java | 17 ++++ .../loopers/infrastructure/pg/PgV1Dto.java | 24 +++++ .../interfaces/api/order/OrderV1Dto.java | 44 ++++++--- .../order/OrderConcurrencyTest.java | 39 +++++++- .../order/OrderFacadeIntegrationTest.java | 92 +++++++------------ .../pg/LoopersPgExecutorTest.java | 71 ++++++++++++++ 15 files changed, 325 insertions(+), 95 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentExecutor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/LoopersPgExecutor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..9c8b2d123 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -5,7 +5,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import java.util.TimeZone; +import org.springframework.cloud.openfeign.EnableFeignClients; +@EnableFeignClients @ConfigurationPropertiesScan @SpringBootApplication public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 62edaab5e..23499c28a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -5,7 +5,8 @@ import com.loopers.domain.order.OrderCommand.PlaceOrder; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; -import com.loopers.domain.point.PointService; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.User; @@ -27,7 +28,7 @@ public class OrderFacade { private final ProductService productService; private final UserService userService; private final OrderService orderService; - private final PointService pointService; + private final PaymentService paymentService; @Transactional public OrderInfo placeOrder(PlaceOrder command) { @@ -43,12 +44,16 @@ public OrderInfo placeOrder(PlaceOrder command) { List orderItems = buildOrderItems(products, command.items()); Order order = orderService.createOrder(user.getId(), orderItems); - long totalAmount = order.getTotalAmount().getValue(); productService.deductStock(products, orderItems); - pointService.deductPoint(user, totalAmount); - return OrderInfo.from(order); + Payment payment = paymentService.processPayment( + user, + order, + command.cardType(), + command.cardNo() + ); + return OrderInfo.from(order, payment); } private List buildOrderItems(List products, List items) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 67f33d2f4..71eb9a554 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -3,21 +3,26 @@ import com.loopers.domain.money.Money; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; +import com.loopers.domain.payment.Payment; import java.util.List; public record OrderInfo( Long orderId, Long userId, List items, - long totalAmount + long totalAmount, + String transactionId, + String paymentStatus ) { - public static OrderInfo from(Order order) { + public static OrderInfo from(Order order, Payment payment) { return new OrderInfo( order.getId(), order.getUserId(), order.getOrderItems().stream().map(OrderItemInfo::from).toList(), - order.getTotalAmount().getValue() + order.getTotalAmount().getValue(), + payment.getTransactionId(), + payment.getStatus().name() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java index 8ac362b47..51f314a38 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java @@ -6,7 +6,9 @@ public class OrderCommand { public record PlaceOrder( Long userId, - List items + List items, + String cardType, + String cardNo ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java index cbf8a4e6f..f68642254 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -13,9 +13,11 @@ import jakarta.persistence.Table; import java.util.UUID; import lombok.AccessLevel; +import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.util.StringUtils; +@Getter @Entity @Table(name = "payment") @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -30,8 +32,9 @@ public class Payment extends BaseEntity { @Column(name = "transaction_id", nullable = false, unique = true) private String transactionId;//๋ฉฑ๋“ฑํ‚ค + @Enumerated(EnumType.STRING) @Column(name = "card_type", nullable = false) - private String cardType; + private CardType cardType; @Column(name = "card_no", nullable = false) private String cardNo; @@ -47,8 +50,8 @@ public class Payment extends BaseEntity { @Column(name = "pg_txn_id") private String pgTxnId; - public Payment(Long orderId, Long userId, Money amount, String cardType, String cardNo) { - validateConstructor(orderId, userId, amount, cardType, cardNo); + public Payment(Long orderId, Long userId, Money amount, CardType cardType, String cardNo) { + validateConstructor(orderId, userId, amount, cardNo); this.orderId = orderId; this.userId = userId; @@ -60,7 +63,7 @@ public Payment(Long orderId, Long userId, Money amount, String cardType, String this.transactionId = UUID.randomUUID().toString(); } - private void validateConstructor(Long orderId, Long userId, Money amount, String cardType, String cardNo) { + private void validateConstructor(Long orderId, Long userId, Money amount, String cardNo) { if (orderId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } @@ -70,11 +73,18 @@ private void validateConstructor(Long orderId, Long userId, Money amount, String if (amount == null) { throw new CoreException(ErrorType.BAD_REQUEST, "๊ฒฐ์ œ ๊ธˆ์•ก์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } - if (!StringUtils.hasText(cardType)) { - throw new CoreException(ErrorType.BAD_REQUEST, "์นด๋“œ ์ข…๋ฅ˜๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); - } if (!StringUtils.hasText(cardNo)) { throw new CoreException(ErrorType.BAD_REQUEST, "์นด๋“œ ๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } } + + public void completePayment(String pgTxnId) { + this.pgTxnId = pgTxnId; + this.status = PaymentStatus.PAID; + } + + public void failPayment() { + this.status = PaymentStatus.FAILED; + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentExecutor.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentExecutor.java new file mode 100644 index 000000000..bdc3efa5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentExecutor.java @@ -0,0 +1,6 @@ +package com.loopers.domain.payment; + +public interface PaymentExecutor { + + String execute(Payment payment); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java index fdc1a1227..ef41bb29e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -11,17 +11,29 @@ public class PaymentService { private final PaymentRepository paymentRepository; + private final PaymentExecutor paymentExecutor; @Transactional - public Payment createPendingPayment(User user, Order order, String cardType, String cardNo) { + public Payment processPayment(User user, Order order, String cardType, String cardNo) { Payment payment = new Payment( order.getId(), user.getId(), order.getTotalAmount(), - cardType, + CardType.valueOf(cardType), cardNo ); - return paymentRepository.save(payment); + paymentRepository.save(payment); + try { + String pgTxnId = paymentExecutor.execute(payment); + + payment.completePayment(pgTxnId); + + } catch (Exception e) { + payment.failPayment(); + throw e; + } + + return payment; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index cae0cd0a1..bff208b84 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -91,6 +91,12 @@ public int getLikeCount() { } public void deductStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐํ•  ์žฌ๊ณ  ์ˆ˜๋Ÿ‰์€ 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (this.stock - quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } this.stock -= quantity; } @@ -100,7 +106,7 @@ public int increaseLikeCount() { } public int decreaseLikeCount() { - if (this.likeCount < 0) { + if (this.likeCount <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "์ข‹์•„์š”์ˆ˜๋Š” 0๋ณด๋‹ค ์ž‘์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); } this.likeCount--; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/LoopersPgExecutor.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/LoopersPgExecutor.java new file mode 100644 index 000000000..cbdfe45ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/LoopersPgExecutor.java @@ -0,0 +1,45 @@ +package com.loopers.infrastructure.pg; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentExecutor; +import com.loopers.infrastructure.pg.PgV1Dto.PgApiResponse; +import com.loopers.infrastructure.pg.PgV1Dto.PgApproveRequest; +import com.loopers.infrastructure.pg.PgV1Dto.PgApproveResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoopersPgExecutor implements PaymentExecutor { + + private final PgClient pgClient; + + @Override + public String execute(Payment payment) { + try { + PgApproveRequest request = new PgApproveRequest( + payment.getTransactionId(), + payment.getCardType().name(), + payment.getCardNo(), + payment.getAmount().getValue(), + "http://localhost:8080/api/v1/callback" + ); + + PgApiResponse responseWrapper = + pgClient.requestPayment(payment.getUserId(), request); + + if ("SUCCESS".equals(responseWrapper.result()) && responseWrapper.data() != null) { + return responseWrapper.data().transactionKey(); + } else { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฒฐ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + + } catch (CoreException e) { + throw e; + } catch (Exception e) { + throw new CoreException(ErrorType.BAD_REQUEST, "PG ์—ฐ๋™ ์˜ค๋ฅ˜: " + e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java new file mode 100644 index 000000000..c1863ffab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.pg; +import com.loopers.infrastructure.pg.PgV1Dto.PgApiResponse; +import com.loopers.infrastructure.pg.PgV1Dto.PgApproveRequest; +import com.loopers.infrastructure.pg.PgV1Dto.PgApproveResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(name = "pg-client", url = "http://localhost:8082") +public interface PgClient { + @PostMapping("/api/v1/payments") + PgApiResponse requestPayment( + @RequestHeader("X-USER-ID") Long userId, + @RequestBody PgApproveRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java new file mode 100644 index 000000000..3007d3bd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java @@ -0,0 +1,24 @@ +package com.loopers.infrastructure.pg; + +public class PgV1Dto { + + public record PgApiResponse( + String result, + T data, + String message + ) {} + + public record PgApproveRequest( + String orderId, + String cardType, + String cardNo, + Long amount, + String callbackUrl + ) {} + + public record PgApproveResponse( + String transactionKey, + String status, + String reason + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 59461c66d..83755cbcb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -2,40 +2,60 @@ import com.loopers.application.order.OrderInfo; import com.loopers.domain.order.OrderCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import java.util.List; public class OrderV1Dto { + public record OrderRequest( + @NotEmpty(message = "์ฃผ๋ฌธ ์ƒํ’ˆ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + List items, - public record OrderRequest(List items) { + @NotBlank(message = "์นด๋“œ ์ข…๋ฅ˜๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String cardType, + + @NotBlank(message = "์นด๋“œ ๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + String cardNo + ) { public OrderCommand.PlaceOrder toCommand(Long userId) { List commandItems = items.stream() .map(item -> new OrderCommand.Item(item.productId(), item.quantity())) .toList(); - return new OrderCommand.PlaceOrder(userId, commandItems); + return new OrderCommand.PlaceOrder( + userId, + commandItems, + cardType, + cardNo + ); } } - public record OrderItemRequest(@NotNull Long productId, - @Positive int quantity - ) { - - } - - public record OrderResponse(Long orderId, - Long userId, - long totalPrice + public record OrderItemRequest( + @NotNull Long productId, + @Positive int quantity + ) {} + + public record OrderResponse( + Long orderId, + Long userId, + long totalPrice, + String transactionId, + String paymentStatus ) { public static OrderResponse from(OrderInfo response) { return new OrderResponse( response.orderId(), response.userId(), - response.totalAmount() + response.totalAmount(), + response.transactionId(), + response.paymentStatus() ); } } } + diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index f6618c9f6..a5ed3d7d2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -1,10 +1,16 @@ package com.loopers.application.order; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import com.loopers.domain.money.Money; import com.loopers.domain.order.OrderCommand.Item; import com.loopers.domain.order.OrderCommand.PlaceOrder; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.payment.PaymentStatus; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.user.User; @@ -24,6 +30,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class OrderConcurrencyTest { @@ -40,6 +47,9 @@ class OrderConcurrencyTest { @Autowired private ProductJpaRepository productJpaRepository; + @MockitoBean + private PaymentService paymentService; + @AfterEach void tearDown() { userJpaRepository.deleteAll(); @@ -55,6 +65,8 @@ class PointConcurrency { @BeforeEach void setUp() { + setupPaymentMock(); + User user = new User("testUser", "test@email.com", "1990-01-01", User.Gender.MALE, new Point(10000L)); this.savedUser = userJpaRepository.saveAndFlush(user); @@ -82,8 +94,7 @@ void point_deduction_concurrency_test() { .mapToObj(i -> CompletableFuture.runAsync(() -> { try { Product targetProduct = distinctProducts.get(i); - PlaceOrder command = new PlaceOrder(savedUser.getId(), - List.of(new Item(targetProduct.getId(), 1))); + PlaceOrder command = createOrderCommand(savedUser.getId(), targetProduct.getId(), 1); orderFacade.placeOrder(command); @@ -99,13 +110,21 @@ void point_deduction_concurrency_test() { // assert User findUser = userRepository.findById(savedUser.getId()).orElseThrow(); - long expectedPoint = 10000L - (1000L * threadCount); + long expectedPoint = 10000L; assertThat(successCount.get()).isEqualTo(threadCount); assertThat(findUser.getPoint().getAmount()).isEqualTo(expectedPoint); } } + private void setupPaymentMock() { + Payment mockPayment = mock(Payment.class); + given(mockPayment.getStatus()).willReturn(PaymentStatus.READY); + + given(paymentService.processPayment(any(), any(), any(), any())) + .willReturn(mockPayment); + } + @Nested @DisplayName("์žฌ๊ณ  ๋™์‹œ์„ฑ (๋‚™๊ด€์  ๋ฝ)") class StockConcurrency { @@ -114,6 +133,8 @@ class StockConcurrency { @BeforeEach void setUp() { + setupPaymentMock(); + Product product = new Product(1L, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(1000L), 100); this.savedProduct = productJpaRepository.saveAndFlush(product); } @@ -141,8 +162,7 @@ void stock_deduction_concurrency_test() { List> futures = IntStream.range(0, threadCount) .mapToObj(i -> CompletableFuture.runAsync(() -> { try { - PlaceOrder command = new PlaceOrder(multiUsers.get(i).getId(), - List.of(new Item(savedProduct.getId(), 1))); + PlaceOrder command = createOrderCommand(multiUsers.get(i).getId(), savedProduct.getId(), 1); orderFacade.placeOrder(command); @@ -164,4 +184,13 @@ void stock_deduction_concurrency_test() { assertThat(findProduct.getStock()).isEqualTo(expectedStock); } } + + private PlaceOrder createOrderCommand(Long userId, Long productId, int quantity) { + return new PlaceOrder( + userId, + List.of(new Item(productId, quantity)), + "SAMSUNG", + "1234-5678-1234-5678" + ); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index a16c4038a..f9ed668ac 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -3,10 +3,16 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; import com.loopers.domain.money.Money; import com.loopers.domain.order.OrderCommand; import com.loopers.domain.order.OrderCommand.Item; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.payment.PaymentStatus; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -23,6 +29,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class OrderFacadeIntegrationTest { @@ -39,19 +46,33 @@ class OrderFacadeIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; + @MockitoBean + private PaymentService paymentService; + @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); } + private OrderCommand.PlaceOrder createOrderCommand(Long userId, List items) { + return new OrderCommand.PlaceOrder( + userId, + items, + "SAMSUNG", + "1234-5678-1234-5678" + ); + } + @DisplayName("์ƒํ’ˆ ์ฃผ๋ฌธ์‹œ") @Nested class PlaceOrder { - @DisplayName("์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๋ฉด ์žฌ๊ณ ์™€ ํฌ์ธํŠธ๊ฐ€ ์ฐจ๊ฐ๋˜๊ณ  ์ฃผ๋ฌธ ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") + + @DisplayName("์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๋ฉด ์žฌ๊ณ ๋Š” ์ฐจ๊ฐ๋˜์ง€๋งŒ, ์นด๋“œ ๊ฒฐ์ œ์ด๋ฏ€๋กœ ํฌ์ธํŠธ๋Š” ์ฐจ๊ฐ๋˜์ง€ ์•Š๋Š”๋‹ค.") @Test void placeOrder_success() { // arrange - User user = new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(100000L)); + Long initialPoint = 100000L; + User user = new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(initialPoint)); User savedUser = userRepository.save(user); Long brandId = 1L; @@ -59,7 +80,15 @@ void placeOrder_success() { Product saveProduct = productRepository.save(product); Item itemCommand = new Item(saveProduct.getId(), 3); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(savedUser.getId(), List.of(itemCommand)); + OrderCommand.PlaceOrder command = createOrderCommand(savedUser.getId(), List.of(itemCommand)); + + Payment mockPayment = mock(Payment.class); + + given(mockPayment.getStatus()).willReturn(PaymentStatus.READY); + given(mockPayment.getPgTxnId()).willReturn("T123456789"); + given(mockPayment.getAmount()).willReturn(new Money(60000L)); + given(paymentService.processPayment(any(), any(), any(), any())) + .willReturn(mockPayment); // act OrderInfo result = orderFacade.placeOrder(command); @@ -71,12 +100,8 @@ void placeOrder_success() { () -> assertThat(result.items()).hasSize(1) ); - Product updatedProduct = productRepository.findById(saveProduct.getId()).orElseThrow(); assertThat(updatedProduct.getStock()).isEqualTo(7); - - User updatedUser = userRepository.findByUserId("userA").orElseThrow(); - assertThat(updatedUser.getPoint().getAmount()).isEqualTo(40000L); } @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์ฃผ๋ฌธ์— ์‹คํŒจํ•œ๋‹ค.") @@ -89,8 +114,8 @@ void placeOrder_fail_insufficient_stock() { new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 2) ); - Item itemCommand = new Item(product.getId(), 3); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + Item itemCommand = new Item(product.getId(), 3); // 3๊ฐœ ์ฃผ๋ฌธ ์‹œ๋„ + OrderCommand.PlaceOrder command = createOrderCommand(user.getId(), List.of(itemCommand)); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -99,54 +124,5 @@ void placeOrder_fail_insufficient_stock() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } - - @DisplayName("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์ฃผ๋ฌธ์— ์‹คํŒจํ•œ๋‹ค.") - @Test - void placeOrder_fail_insufficient_point() { - // arrange - User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(10000L))); - - Product product = productRepository.save( - new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) - ); - - Item itemCommand = new Item(product.getId(), 1); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); - - // act & assert - CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.placeOrder(command); - }); - - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - - @DisplayName("ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ์œผ๋กœ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•˜๋ฉด, ์ฐจ๊ฐ๋˜์—ˆ๋˜ ์žฌ๊ณ ๋Š” ๋กค๋ฐฑ๋˜์–ด ์›์ƒ๋ณต๊ตฌ๋œ๋‹ค.") - @Test - void placeOrder_transaction_rollback_test() { - // arrange - Product product = productRepository.save( - new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) - ); - - User user = userRepository.save( - new User("userRollback", "rollback@email.com", "2025-11-11", Gender.MALE, new Point(0L)) - ); - - Item itemCommand = new Item(product.getId(), 1); // 1๊ฐœ ์ฃผ๋ฌธ ์‹œ๋„ - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); - - // act - assertThrows(CoreException.class, () -> { - orderFacade.placeOrder(command); - }); - - // assert - Product rollbackedProduct = productRepository.findById(product.getId()).orElseThrow(); - - assertThat(rollbackedProduct.getStock()).isEqualTo(10); - } } - - } diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java new file mode 100644 index 000000000..3cf9e977e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java @@ -0,0 +1,71 @@ +package com.loopers.infrastructure.pg; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +import com.loopers.domain.payment.Payment; +import com.loopers.infrastructure.pg.PgV1Dto.PgApiResponse; +import com.loopers.infrastructure.pg.PgV1Dto.PgApproveResponse; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LoopersPgExecutorTest { + + @Mock + private PgClient pgClient; // ๊ฐ€์งœ PG ํด๋ผ์ด์–ธํŠธ + + @InjectMocks + private LoopersPgExecutor loopersPgExecutor; // ํ…Œ์ŠคํŠธ ๋Œ€์ƒ + + @Test + @DisplayName("PG ์Šน์ธ ์š”์ฒญ์ด ์„ฑ๊ณตํ•˜๋ฉด ํŠธ๋žœ์žญ์…˜ ํ‚ค๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void execute_success() { + // given + // 1. ํ…Œ์ŠคํŠธ์šฉ ๊ฒฐ์ œ ์ •๋ณด ๊ฐ€์งœ ์ƒ์„ฑ (ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ Mockingํ•˜๊ฑฐ๋‚˜ ๊ฐ์ฒด ์ƒ์„ฑ) + Payment mockPayment = org.mockito.Mockito.mock(Payment.class); + given(mockPayment.getTransactionId()).willReturn("ORDER-123"); + given(mockPayment.getCardType()).willReturn(com.loopers.domain.payment.CardType.SAMSUNG); + given(mockPayment.getCardNo()).willReturn("1234-5678"); + given(mockPayment.getAmount()).willReturn(new com.loopers.domain.money.Money(1000L)); + given(mockPayment.getUserId()).willReturn(1L); + + PgApproveResponse approveData = new PgApproveResponse("TX-KEY-001", "DONE", "OK"); + PgApiResponse successResponse = new PgApiResponse<>("SUCCESS", approveData, "์„ฑ๊ณต"); + + given(pgClient.requestPayment(eq(1L), any())).willReturn(successResponse); + + // when + String resultTransactionKey = loopersPgExecutor.execute(mockPayment); + + // then + assertEquals("TX-KEY-001", resultTransactionKey); + } + + @Test + @DisplayName("PG ์‘๋‹ต์ด FAIL์ด๋ฉด CoreException์„ ๋˜์ง„๋‹ค") + void execute_fail() { + // given + Payment mockPayment = org.mockito.Mockito.mock(Payment.class); + given(mockPayment.getTransactionId()).willReturn("ORDER-123"); + given(mockPayment.getCardType()).willReturn(com.loopers.domain.payment.CardType.SAMSUNG); + given(mockPayment.getCardNo()).willReturn("1234-5678"); + given(mockPayment.getAmount()).willReturn(new com.loopers.domain.money.Money(1000L)); + given(mockPayment.getUserId()).willReturn(1L); + + PgApiResponse failResponse = new PgApiResponse<>("FAIL", null, "์ž”์•ก ๋ถ€์กฑ"); + given(pgClient.requestPayment(eq(1L), any())).willReturn(failResponse); + + // when & then + assertThrows(CoreException.class, () -> { + loopersPgExecutor.execute(mockPayment); + }); + } +} From cd84589332c0465ac496eaafc6ad735bee363071 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 22:44:35 +0900 Subject: [PATCH 123/164] =?UTF-8?q?chore:=20FeignClient=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=8B=9C=EA=B0=84=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(connectTimeout,=20readTimeout=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/pg-simulator/src/main/resources/application.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml index addf0e29c..caae22ccb 100644 --- a/apps/pg-simulator/src/main/resources/application.yml +++ b/apps/pg-simulator/src/main/resources/application.yml @@ -23,6 +23,13 @@ spring: - redis.yml - logging.yml - monitoring.yml + cloud: + openfeign: + client: + config: + default: + connectTimeout: 1000 + readTimeout: 1000 datasource: mysql-jpa: From c194e30808740e8d5454ff6f7bdc318dacebde23 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 22:45:38 +0900 Subject: [PATCH 124/164] =?UTF-8?q?add:=20PG=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A3=BC=EB=AC=B8=20API=20http=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 --- .../pg/LoopersPgExecutorTest.java | 5 ++--- http/commerce-api/order-v1.http | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 http/commerce-api/order-v1.http diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java index 3cf9e977e..a33ed3d81 100644 --- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/pg/LoopersPgExecutorTest.java @@ -20,16 +20,15 @@ class LoopersPgExecutorTest { @Mock - private PgClient pgClient; // ๊ฐ€์งœ PG ํด๋ผ์ด์–ธํŠธ + private PgClient pgClient; @InjectMocks - private LoopersPgExecutor loopersPgExecutor; // ํ…Œ์ŠคํŠธ ๋Œ€์ƒ + private LoopersPgExecutor loopersPgExecutor; @Test @DisplayName("PG ์Šน์ธ ์š”์ฒญ์ด ์„ฑ๊ณตํ•˜๋ฉด ํŠธ๋žœ์žญ์…˜ ํ‚ค๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") void execute_success() { // given - // 1. ํ…Œ์ŠคํŠธ์šฉ ๊ฒฐ์ œ ์ •๋ณด ๊ฐ€์งœ ์ƒ์„ฑ (ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ Mockingํ•˜๊ฑฐ๋‚˜ ๊ฐ์ฒด ์ƒ์„ฑ) Payment mockPayment = org.mockito.Mockito.mock(Payment.class); given(mockPayment.getTransactionId()).willReturn("ORDER-123"); given(mockPayment.getCardType()).willReturn(com.loopers.domain.payment.CardType.SAMSUNG); diff --git a/http/commerce-api/order-v1.http b/http/commerce-api/order-v1.http new file mode 100644 index 000000000..aefe2ef7e --- /dev/null +++ b/http/commerce-api/order-v1.http @@ -0,0 +1,19 @@ +### ์ฃผ๋ฌธ ์ƒ์„ฑ ๋ฐ ๊ฒฐ์ œ ์š”์ฒญ +POST {{commerce-api}}/api/v1/orders +Content-Type: application/json +X-USER-ID: 1 + +{ + "orderItems": [ + { + "productId": 1, + "quantity": 2 + }, + { + "productId": 2, + "quantity": 1 + } + ], + "cardType": "KB", + "cardNo": "1234-1234-1234-1234" +} \ No newline at end of file From 3b17ea480b1f0b70acb159a9c814c9530cfa1d04 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 4 Dec 2025 23:05:59 +0900 Subject: [PATCH 125/164] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=EB=90=9C=20=EA=B1=B4=EC=9D=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EB=B6=88=EA=B0=80=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80(=ED=9A=8C=EA=B7=80=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/payment/Payment.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java index f68642254..d948dba46 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -79,6 +79,9 @@ private void validateConstructor(Long orderId, Long userId, Money amount, String } public void completePayment(String pgTxnId) { + if (this.status == PaymentStatus.PAID || this.status == PaymentStatus.CANCELLED) { + return; + } this.pgTxnId = pgTxnId; this.status = PaymentStatus.PAID; } From b6ebc4c466528f2ac229cc1261ac15f3f8172d56 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 5 Dec 2025 12:36:36 +0900 Subject: [PATCH 126/164] =?UTF-8?q?chore:=20=EC=A0=95=EB=A6=AC=EB=90=9C=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=9E=AC=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EB=B0=8F=20Resilience4j=20=EC=84=A4=EC=A0=95=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(Retry,=20CircuitBreaker=20=EC=84=B8=EB=B6=80=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=ED=8F=AC=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 2 +- settings.gradle.kts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index fbfeee7e7..906943a1d 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -21,7 +21,7 @@ dependencies { testImplementation(testFixtures(project(":modules:redis"))) // Resilience4j (Spring Boot 3.x ๊ธฐ์ค€) - implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0") + implementation("io.github.resilience4j:resilience4j-spring-boot3") // AOP implementation("org.springframework.boot:spring-boot-starter-aop") diff --git a/settings.gradle.kts b/settings.gradle.kts index 40a9e0cc3..337a38e42 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ rootProject.name = "loopers-java-spring-template" include( ":apps:commerce-api", ":apps:commerce-streamer", + ":apps:pg-simulator", ":apps:commerce-batch", ":modules:jpa", ":modules:redis", @@ -10,7 +11,7 @@ include( ":supports:jackson", ":supports:logging", ":supports:monitoring", - ":apps:pg-simulator", + ) // configurations From 31f75287b35031dda6a1a6e1149e635c1b068b16 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 5 Dec 2025 12:41:36 +0900 Subject: [PATCH 127/164] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=B0=EC=A0=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EB=B3=B5=EA=B5=AC=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80=20-=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=B3=B4=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F?= =?UTF-8?q?=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B0=A8=EA=B0=90=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A3=BC=EB=AC=B8=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EB=B3=80=EA=B2=BD(=EC=A3=BC=EB=AC=B8=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EA=B3=BC=20=EA=B2=B0=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=B4)=20-=20Payment?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC=20(Facade,=20DTO,=20Command=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80)=20-=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=A0=95=EC=B2=B4=EB=90=9C=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=9E=90=EB=8F=99=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20-=20PG=20=EA=B4=80=EB=A0=A8=20=EC=97=94?= =?UTF-8?q?=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=B0=8F=20DTO=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=ED=83=80=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0/=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/CommerceApiApplication.java | 2 + .../application/order/OrderFacade.java | 15 +-- .../loopers/application/order/OrderInfo.java | 10 +- .../application/payment/PaymentFacade.java | 51 ++++++++++ .../application/payment/PaymentInfo.java | 27 ++++++ .../payment/PaymentRecoveryScheduler.java | 52 +++++++++++ .../loopers/domain/order/OrderCommand.java | 4 +- .../com/loopers/domain/payment/Payment.java | 13 ++- .../domain/payment/PaymentCommand.java | 25 +++++ .../domain/payment/PaymentRepository.java | 10 ++ .../domain/payment/PaymentService.java | 52 +++++++---- .../payment/PaymentJpaRepository.java | 7 ++ .../payment/PaymentRepositoryImpl.java | 20 ++++ .../loopers/infrastructure/pg/PgClient.java | 15 +++ .../loopers/infrastructure/pg/PgV1Dto.java | 13 +++ .../interfaces/api/order/OrderV1Dto.java | 21 +---- .../api/payment/PaymentV1ApiSpec.java | 31 +++++++ .../api/payment/PaymentV1Controller.java | 53 +++++++++++ .../interfaces/api/payment/PaymentV1Dto.java | 59 ++++++++++++ .../order/OrderConcurrencyTest.java | 39 +------- .../order/OrderFacadeIntegrationTest.java | 92 ++++++++++++------- 21 files changed, 486 insertions(+), 125 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9c8b2d123..0649e4a67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -6,7 +6,9 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import java.util.TimeZone; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableFeignClients @ConfigurationPropertiesScan @SpringBootApplication diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 23499c28a..62edaab5e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -5,8 +5,7 @@ import com.loopers.domain.order.OrderCommand.PlaceOrder; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; -import com.loopers.domain.payment.Payment; -import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.point.PointService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.User; @@ -28,7 +27,7 @@ public class OrderFacade { private final ProductService productService; private final UserService userService; private final OrderService orderService; - private final PaymentService paymentService; + private final PointService pointService; @Transactional public OrderInfo placeOrder(PlaceOrder command) { @@ -44,16 +43,12 @@ public OrderInfo placeOrder(PlaceOrder command) { List orderItems = buildOrderItems(products, command.items()); Order order = orderService.createOrder(user.getId(), orderItems); + long totalAmount = order.getTotalAmount().getValue(); productService.deductStock(products, orderItems); + pointService.deductPoint(user, totalAmount); - Payment payment = paymentService.processPayment( - user, - order, - command.cardType(), - command.cardNo() - ); - return OrderInfo.from(order, payment); + return OrderInfo.from(order); } private List buildOrderItems(List products, List items) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 71eb9a554..51b23affe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -10,19 +10,15 @@ public record OrderInfo( Long orderId, Long userId, List items, - long totalAmount, - String transactionId, - String paymentStatus + long totalAmount ) { - public static OrderInfo from(Order order, Payment payment) { + public static OrderInfo from(Order order) { return new OrderInfo( order.getId(), order.getUserId(), order.getOrderItems().stream().map(OrderItemInfo::from).toList(), - order.getTotalAmount().getValue(), - payment.getTransactionId(), - payment.getStatus().name() + order.getTotalAmount().getValue() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java new file mode 100644 index 000000000..1156396e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -0,0 +1,51 @@ +package com.loopers.application.payment; + +import com.loopers.domain.money.Money; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentCommand; +import com.loopers.domain.payment.PaymentExecutor; +import com.loopers.domain.payment.PaymentService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PaymentFacade { + + private final PaymentService paymentService; + private final PaymentExecutor paymentExecutor; + + public Payment processPaymentRequest(PaymentCommand.CreatePayment command) { + + return paymentService.findValidPayment(command.orderId()) + .orElseGet(() -> { + Payment newPayment = paymentService.createPendingPayment(command.userId(), + command.orderId(), + new Money(command.amount()), + command.cardType(), + command.cardNo()); + + try { + String pgTxnId = paymentExecutor.execute(newPayment); + + paymentService.registerPgToken(newPayment, pgTxnId); + + return newPayment; + } catch (Exception e) { + paymentService.failPayment(newPayment); + throw e; + } + }); + } + + + public void handlePaymentCallback(String pgTxnId, boolean isSuccess) { + Payment payment = paymentService.getPaymentByPgTxnId(pgTxnId); + + if (isSuccess) { + paymentService.completePayment(payment); + } else { + paymentService.failPayment(payment); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java new file mode 100644 index 000000000..f4ec35c05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; + +public record PaymentInfo( + String paymentId, + Long orderId, + CardType cardType, + String cardNo, + Long amount, + PaymentStatus status, + String transactionId) { + + public static PaymentInfo from(Payment payment) { + return new PaymentInfo( + payment.getId().toString(), + payment.getOrderId(), + payment.getCardType(), + payment.getCardNo(), + payment.getAmount().getValue(), + payment.getStatus(), + payment.getTransactionId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java new file mode 100644 index 000000000..925d2f20e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java @@ -0,0 +1,52 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.infrastructure.pg.PgClient; +import com.loopers.infrastructure.pg.PgV1Dto.PgDetail; +import com.loopers.infrastructure.pg.PgV1Dto.PgOrderResponse; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PaymentRecoveryScheduler { + + private final PaymentRepository paymentRepository; + private final PgClient pgClient; + + @Scheduled(fixedDelay = 60000) + @Transactional + public void recover() { + LocalDateTime timeLimit = LocalDateTime.now().minusMinutes(5); + List stuckPayments = paymentRepository.findAllByStatusAndCreatedAtBefore( + PaymentStatus.READY, timeLimit + ); + + for (Payment payment : stuckPayments) { + try { + PgOrderResponse response = pgClient.getTransactionsByOrder( + payment.getUserId(), String.valueOf(payment.getOrderId()) + ); + + if (response.transactions() != null && !response.transactions().isEmpty()) { + PgDetail detail = response.transactions().get(0); + + if ("SUCCESS".equals(detail.status())) { + payment.completePayment(); + } else if ("FAIL".equals(detail.status())) { + payment.failPayment(); + } + } else { + payment.failPayment(); + } + } catch (Exception e) { + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java index 51f314a38..8ac362b47 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java @@ -6,9 +6,7 @@ public class OrderCommand { public record PlaceOrder( Long userId, - List items, - String cardType, - String cardNo + List items ) { } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java index d948dba46..7554eeefa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -78,16 +78,25 @@ private void validateConstructor(Long orderId, Long userId, Money amount, String } } - public void completePayment(String pgTxnId) { + public void completePayment() { if (this.status == PaymentStatus.PAID || this.status == PaymentStatus.CANCELLED) { return; } - this.pgTxnId = pgTxnId; this.status = PaymentStatus.PAID; } public void failPayment() { + if (this.status == PaymentStatus.PAID) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฏธ ์„ฑ๊ณตํ•œ ๊ฒฐ์ œ๋Š” ์‹คํŒจ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } this.status = PaymentStatus.FAILED; } + public boolean isProcessingOrCompleted() { + return this.status == PaymentStatus.PAID || this.status == PaymentStatus.READY; + } + + public void setPgTxnId(String pgTxnId) { + this.pgTxnId = pgTxnId; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentCommand.java new file mode 100644 index 000000000..20fe365fe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentCommand.java @@ -0,0 +1,25 @@ +package com.loopers.domain.payment; + +import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentRequest; + +public class PaymentCommand { + + public record CreatePayment( + Long userId, + Long orderId, + Long amount, + CardType cardType, + String cardNo + ) { + + public static CreatePayment from(Long userId, PaymentRequest request) { + return new CreatePayment( + userId, + request.orderId(), + request.amount(), + request.cardType(), + request.cardNo() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java index 336bdd0ac..3b334df34 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -1,6 +1,16 @@ package com.loopers.domain.payment; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + public interface PaymentRepository { Payment save(Payment payment); + + Optional findByOrderId(Long id); + + List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, LocalDateTime timeLimit); + + Optional findByPgTxnId(String pgTxnId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java index ef41bb29e..b5551cf7d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -1,39 +1,55 @@ package com.loopers.domain.payment; -import com.loopers.domain.order.Order; -import com.loopers.domain.user.User; -import jakarta.transaction.Transactional; +import com.loopers.domain.money.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component +@Transactional(readOnly = true) public class PaymentService { private final PaymentRepository paymentRepository; - private final PaymentExecutor paymentExecutor; + + public Optional findValidPayment(Long orderId) { + return paymentRepository.findByOrderId(orderId) + .filter(Payment::isProcessingOrCompleted); + } @Transactional - public Payment processPayment(User user, Order order, String cardType, String cardNo) { + public Payment createPendingPayment(Long userId, Long orderId, Money amount, CardType cardType, String cardNo) { Payment payment = new Payment( - order.getId(), - user.getId(), - order.getTotalAmount(), - CardType.valueOf(cardType), + orderId, + userId, + amount, + cardType, cardNo ); + return paymentRepository.save(payment); + } - paymentRepository.save(payment); - try { - String pgTxnId = paymentExecutor.execute(payment); + @Transactional + public void registerPgToken(Payment payment, String pgTxnId) { + payment.setPgTxnId(pgTxnId); + } - payment.completePayment(pgTxnId); + @Transactional + public void failPayment(Payment payment) { + payment.failPayment(); + } - } catch (Exception e) { - payment.failPayment(); - throw e; - } + public Payment getPaymentByPgTxnId(String pgTxnId) { + return paymentRepository.findByPgTxnId(pgTxnId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๊ฒฐ์ œ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + } - return payment; + @Transactional + public void completePayment(Payment payment) { + payment.completePayment(); } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java index 937e6760a..dd790fb32 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -1,8 +1,15 @@ package com.loopers.infrastructure.payment; import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PaymentJpaRepository extends JpaRepository { + List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, LocalDateTime createdAt); + + Optional findByPgTxnId(String pgTxnId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java index 5c49b39e5..c91562bb2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -2,6 +2,10 @@ import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -15,4 +19,20 @@ public class PaymentRepositoryImpl implements PaymentRepository { public Payment save(Payment payment) { return paymentJpaRepository.save(payment); } + + @Override + public Optional findByOrderId(Long id) { + return paymentJpaRepository.findById(id); + } + + @Override + public List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, + LocalDateTime timeLimit) { + return paymentJpaRepository.findAllByStatusAndCreatedAtBefore(paymentStatus, timeLimit); + } + + @Override + public Optional findByPgTxnId(String pgTxnId) { + return paymentJpaRepository.findByPgTxnId(pgTxnId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java index c1863ffab..bbc98364c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgClient.java @@ -1,17 +1,32 @@ package com.loopers.infrastructure.pg; + import com.loopers.infrastructure.pg.PgV1Dto.PgApiResponse; import com.loopers.infrastructure.pg.PgV1Dto.PgApproveRequest; import com.loopers.infrastructure.pg.PgV1Dto.PgApproveResponse; +import com.loopers.infrastructure.pg.PgV1Dto.PgOrderResponse; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import io.github.resilience4j.retry.annotation.Retry; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; @FeignClient(name = "pg-client", url = "http://localhost:8082") public interface PgClient { + + @CircuitBreaker(name = "pg-client") + @Retry(name = "pg-client") @PostMapping("/api/v1/payments") PgApiResponse requestPayment( @RequestHeader("X-USER-ID") Long userId, @RequestBody PgApproveRequest request ); + + @GetMapping("/api/v1/payments") + PgOrderResponse getTransactionsByOrder( + @RequestHeader("X-USER-ID") Long userId, + @RequestParam("orderId") String orderId + ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java index 3007d3bd3..c0ce1e382 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/pg/PgV1Dto.java @@ -1,5 +1,7 @@ package com.loopers.infrastructure.pg; +import java.util.List; + public class PgV1Dto { public record PgApiResponse( @@ -21,4 +23,15 @@ public record PgApproveResponse( String status, String reason ) {} + + public record PgOrderResponse( + List transactions + ) {} + + public record PgDetail( + String transactionKey, + String paymentKey, + String orderId, + String status + ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 83755cbcb..96ee350e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -2,7 +2,6 @@ import com.loopers.application.order.OrderInfo; import com.loopers.domain.order.OrderCommand; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -11,13 +10,7 @@ public class OrderV1Dto { public record OrderRequest( @NotEmpty(message = "์ฃผ๋ฌธ ์ƒํ’ˆ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") - List items, - - @NotBlank(message = "์นด๋“œ ์ข…๋ฅ˜๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") - String cardType, - - @NotBlank(message = "์นด๋“œ ๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") - String cardNo + List items ) { public OrderCommand.PlaceOrder toCommand(Long userId) { @@ -27,9 +20,7 @@ public OrderCommand.PlaceOrder toCommand(Long userId) { return new OrderCommand.PlaceOrder( userId, - commandItems, - cardType, - cardNo + commandItems ); } } @@ -42,18 +33,14 @@ public record OrderItemRequest( public record OrderResponse( Long orderId, Long userId, - long totalPrice, - String transactionId, - String paymentStatus + long totalPrice ) { public static OrderResponse from(OrderInfo response) { return new OrderResponse( response.orderId(), response.userId(), - response.totalAmount(), - response.transactionId(), - response.paymentStatus() + response.totalAmount() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java new file mode 100644 index 000000000..1fd2e85c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.payment.PaymentV1Dto.CallbackRequest; +import com.loopers.interfaces.api.payment.PaymentV1Dto.CallbackResponse; +import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentRequest; +import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@Tag(name = "Payment V1 API", description = "๊ฒฐ์ œ/PG ๊ด€๋ จ API ์ž…๋‹ˆ๋‹ค.") +public interface PaymentV1ApiSpec { + + @Operation( + summary = "PG ๊ฒฐ์ œ ์š”์ฒญ", + description = "์‚ฌ์šฉ์ž๋กœ๋ถ€ํ„ฐ ๊ฒฐ์ œ ์š”์ฒญ์„ ๋ฐ›์•„ PG์‚ฌ๋กœ ๊ฒฐ์ œ ์š”์ฒญ์„ ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse requestPayment( + @RequestHeader("X-USER-ID") Long userId, + @RequestBody PaymentRequest dto); + + @Operation( + summary = "PG ๊ฒฐ์ œ ์ฝœ๋ฐฑ ์ฒ˜๋ฆฌ", + description = "PG์‚ฌ์—์„œ ๊ฒฐ์ œ ์™„๋ฃŒ ํ›„ ํ˜ธ์ถœํ•˜๋Š” Webhook ์—”๋“œํฌ์ธํŠธ์ž…๋‹ˆ๋‹ค. ๊ฒฐ์ œ ์ƒํƒœ๋ฅผ ์ตœ์ข… ํ™•์ •ํ•ฉ๋‹ˆ๋‹ค." + ) + ApiResponse handlePaymentCallback( + @RequestBody CallbackRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java new file mode 100644 index 000000000..2860b9350 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentFacade; +import com.loopers.application.payment.PaymentInfo; +import com.loopers.domain.payment.PaymentCommand; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.payment.PaymentV1Dto.CallbackRequest; +import com.loopers.interfaces.api.payment.PaymentV1Dto.CallbackResponse; +import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentRequest; +import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentResponse; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/payments") +public class PaymentV1Controller implements PaymentV1ApiSpec { + + private final PaymentFacade paymentFacade; + + @Override + @PostMapping + public ApiResponse requestPayment( + @RequestHeader("X-USER-ID") Long userId, + @RequestBody PaymentRequest request) { + PaymentCommand.CreatePayment command = PaymentCommand.CreatePayment.from(userId, request); + PaymentInfo paymentInfo = PaymentInfo.from(paymentFacade.processPaymentRequest(command)); + return ApiResponse.success(PaymentResponse.from(paymentInfo)); + } + + @Override + @PostMapping("/callback") + public ApiResponse handlePaymentCallback(@RequestBody CallbackRequest request) { + try { + boolean isSuccess = "SUCCESS".equals(request.status()); + + paymentFacade.handlePaymentCallback(request.transactionKey(), isSuccess); + + return ApiResponse.success(CallbackResponse.success()); + + } catch (Exception e) { + + return new ApiResponse<>( + ApiResponse.Metadata.fail("CALLBACK_ERROR", e.getMessage()), + CallbackResponse.fail(e.getMessage()) + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java new file mode 100644 index 000000000..716909753 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentInfo; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.PaymentStatus; + +public class PaymentV1Dto { + + public record PaymentRequest( + Long orderId, + CardType cardType, + String cardNo, + Long amount + ) { + + } + + public record PaymentResponse( + String paymentId, + Long orderId, + CardType cardType, + Long amount, + PaymentStatus status + ) { + + public static PaymentResponse from(PaymentInfo paymentInfo) { + return new PaymentResponse( + paymentInfo.paymentId(), + paymentInfo.orderId(), + paymentInfo.cardType(), + paymentInfo.amount(), + paymentInfo.status() + ); + } + } + + public record CallbackRequest( + String transactionKey, + String orderId, + String status, + String reason + ) { + + } + + public record CallbackResponse( + String status, + String message + ) { + + public static CallbackResponse success() { + return new CallbackResponse("SUCCESS", "Callback processed successfully"); + } + + public static CallbackResponse fail(String message) { + return new CallbackResponse("FAIL", message); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index a5ed3d7d2..f6618c9f6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -1,16 +1,10 @@ package com.loopers.application.order; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; import com.loopers.domain.money.Money; import com.loopers.domain.order.OrderCommand.Item; import com.loopers.domain.order.OrderCommand.PlaceOrder; -import com.loopers.domain.payment.Payment; -import com.loopers.domain.payment.PaymentService; -import com.loopers.domain.payment.PaymentStatus; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.user.User; @@ -30,7 +24,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class OrderConcurrencyTest { @@ -47,9 +40,6 @@ class OrderConcurrencyTest { @Autowired private ProductJpaRepository productJpaRepository; - @MockitoBean - private PaymentService paymentService; - @AfterEach void tearDown() { userJpaRepository.deleteAll(); @@ -65,8 +55,6 @@ class PointConcurrency { @BeforeEach void setUp() { - setupPaymentMock(); - User user = new User("testUser", "test@email.com", "1990-01-01", User.Gender.MALE, new Point(10000L)); this.savedUser = userJpaRepository.saveAndFlush(user); @@ -94,7 +82,8 @@ void point_deduction_concurrency_test() { .mapToObj(i -> CompletableFuture.runAsync(() -> { try { Product targetProduct = distinctProducts.get(i); - PlaceOrder command = createOrderCommand(savedUser.getId(), targetProduct.getId(), 1); + PlaceOrder command = new PlaceOrder(savedUser.getId(), + List.of(new Item(targetProduct.getId(), 1))); orderFacade.placeOrder(command); @@ -110,21 +99,13 @@ void point_deduction_concurrency_test() { // assert User findUser = userRepository.findById(savedUser.getId()).orElseThrow(); - long expectedPoint = 10000L; + long expectedPoint = 10000L - (1000L * threadCount); assertThat(successCount.get()).isEqualTo(threadCount); assertThat(findUser.getPoint().getAmount()).isEqualTo(expectedPoint); } } - private void setupPaymentMock() { - Payment mockPayment = mock(Payment.class); - given(mockPayment.getStatus()).willReturn(PaymentStatus.READY); - - given(paymentService.processPayment(any(), any(), any(), any())) - .willReturn(mockPayment); - } - @Nested @DisplayName("์žฌ๊ณ  ๋™์‹œ์„ฑ (๋‚™๊ด€์  ๋ฝ)") class StockConcurrency { @@ -133,8 +114,6 @@ class StockConcurrency { @BeforeEach void setUp() { - setupPaymentMock(); - Product product = new Product(1L, "์ธ๊ธฐ์ƒํ’ˆ", "์„ค๋ช…", new Money(1000L), 100); this.savedProduct = productJpaRepository.saveAndFlush(product); } @@ -162,7 +141,8 @@ void stock_deduction_concurrency_test() { List> futures = IntStream.range(0, threadCount) .mapToObj(i -> CompletableFuture.runAsync(() -> { try { - PlaceOrder command = createOrderCommand(multiUsers.get(i).getId(), savedProduct.getId(), 1); + PlaceOrder command = new PlaceOrder(multiUsers.get(i).getId(), + List.of(new Item(savedProduct.getId(), 1))); orderFacade.placeOrder(command); @@ -184,13 +164,4 @@ void stock_deduction_concurrency_test() { assertThat(findProduct.getStock()).isEqualTo(expectedStock); } } - - private PlaceOrder createOrderCommand(Long userId, Long productId, int quantity) { - return new PlaceOrder( - userId, - List.of(new Item(productId, quantity)), - "SAMSUNG", - "1234-5678-1234-5678" - ); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index f9ed668ac..a16c4038a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -3,16 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; import com.loopers.domain.money.Money; import com.loopers.domain.order.OrderCommand; import com.loopers.domain.order.OrderCommand.Item; -import com.loopers.domain.payment.Payment; -import com.loopers.domain.payment.PaymentService; -import com.loopers.domain.payment.PaymentStatus; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -29,7 +23,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest class OrderFacadeIntegrationTest { @@ -46,33 +39,19 @@ class OrderFacadeIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; - @MockitoBean - private PaymentService paymentService; - @AfterEach void tearDown() { databaseCleanUp.truncateAllTables(); } - private OrderCommand.PlaceOrder createOrderCommand(Long userId, List items) { - return new OrderCommand.PlaceOrder( - userId, - items, - "SAMSUNG", - "1234-5678-1234-5678" - ); - } - @DisplayName("์ƒํ’ˆ ์ฃผ๋ฌธ์‹œ") @Nested class PlaceOrder { - - @DisplayName("์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๋ฉด ์žฌ๊ณ ๋Š” ์ฐจ๊ฐ๋˜์ง€๋งŒ, ์นด๋“œ ๊ฒฐ์ œ์ด๋ฏ€๋กœ ํฌ์ธํŠธ๋Š” ์ฐจ๊ฐ๋˜์ง€ ์•Š๋Š”๋‹ค.") + @DisplayName("์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๋ฉด ์žฌ๊ณ ์™€ ํฌ์ธํŠธ๊ฐ€ ์ฐจ๊ฐ๋˜๊ณ  ์ฃผ๋ฌธ ์ •๋ณด๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค.") @Test void placeOrder_success() { // arrange - Long initialPoint = 100000L; - User user = new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(initialPoint)); + User user = new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(100000L)); User savedUser = userRepository.save(user); Long brandId = 1L; @@ -80,15 +59,7 @@ void placeOrder_success() { Product saveProduct = productRepository.save(product); Item itemCommand = new Item(saveProduct.getId(), 3); - OrderCommand.PlaceOrder command = createOrderCommand(savedUser.getId(), List.of(itemCommand)); - - Payment mockPayment = mock(Payment.class); - - given(mockPayment.getStatus()).willReturn(PaymentStatus.READY); - given(mockPayment.getPgTxnId()).willReturn("T123456789"); - given(mockPayment.getAmount()).willReturn(new Money(60000L)); - given(paymentService.processPayment(any(), any(), any(), any())) - .willReturn(mockPayment); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(savedUser.getId(), List.of(itemCommand)); // act OrderInfo result = orderFacade.placeOrder(command); @@ -100,8 +71,12 @@ void placeOrder_success() { () -> assertThat(result.items()).hasSize(1) ); + Product updatedProduct = productRepository.findById(saveProduct.getId()).orElseThrow(); assertThat(updatedProduct.getStock()).isEqualTo(7); + + User updatedUser = userRepository.findByUserId("userA").orElseThrow(); + assertThat(updatedUser.getPoint().getAmount()).isEqualTo(40000L); } @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์ฃผ๋ฌธ์— ์‹คํŒจํ•œ๋‹ค.") @@ -114,8 +89,8 @@ void placeOrder_fail_insufficient_stock() { new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 2) ); - Item itemCommand = new Item(product.getId(), 3); // 3๊ฐœ ์ฃผ๋ฌธ ์‹œ๋„ - OrderCommand.PlaceOrder command = createOrderCommand(user.getId(), List.of(itemCommand)); + Item itemCommand = new Item(product.getId(), 3); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -124,5 +99,54 @@ void placeOrder_fail_insufficient_stock() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } + + @DisplayName("ํฌ์ธํŠธ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์ฃผ๋ฌธ์— ์‹คํŒจํ•œ๋‹ค.") + @Test + void placeOrder_fail_insufficient_point() { + // arrange + User user = userRepository.save(new User("userA", "a@email.com", "2025-11-11", Gender.MALE, new Point(10000L))); + + Product product = productRepository.save( + new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) + ); + + Item itemCommand = new Item(product.getId(), 1); + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + + // act & assert + CoreException exception = assertThrows(CoreException.class, () -> { + orderFacade.placeOrder(command); + }); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("ํฌ์ธํŠธ ์ž”์•ก ๋ถ€์กฑ์œผ๋กœ ์ฃผ๋ฌธ์ด ์‹คํŒจํ•˜๋ฉด, ์ฐจ๊ฐ๋˜์—ˆ๋˜ ์žฌ๊ณ ๋Š” ๋กค๋ฐฑ๋˜์–ด ์›์ƒ๋ณต๊ตฌ๋œ๋‹ค.") + @Test + void placeOrder_transaction_rollback_test() { + // arrange + Product product = productRepository.save( + new Product(1L, "Product A", "์„ค๋ช…", new Money(20000L), 10) + ); + + User user = userRepository.save( + new User("userRollback", "rollback@email.com", "2025-11-11", Gender.MALE, new Point(0L)) + ); + + Item itemCommand = new Item(product.getId(), 1); // 1๊ฐœ ์ฃผ๋ฌธ ์‹œ๋„ + OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + + // act + assertThrows(CoreException.class, () -> { + orderFacade.placeOrder(command); + }); + + // assert + Product rollbackedProduct = productRepository.findById(product.getId()).orElseThrow(); + + assertThat(rollbackedProduct.getStock()).isEqualTo(10); + } } + + } From 9969cbcf0fcde65c943c826c53cd62b17d206117 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 5 Dec 2025 13:25:11 +0900 Subject: [PATCH 128/164] add: HTTP request samples for Payment API (v1) including payment and callback scenarios --- http/commerce-api/payment-v1.http | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 http/commerce-api/payment-v1.http diff --git a/http/commerce-api/payment-v1.http b/http/commerce-api/payment-v1.http new file mode 100644 index 000000000..3a7103472 --- /dev/null +++ b/http/commerce-api/payment-v1.http @@ -0,0 +1,26 @@ +### ๊ฒฐ์ œ ์š”์ฒญ +POST {{commerce-api}}/api/v1/payments +X-USER-ID: 1 +Content-Type: application/json + +{ + "orderId": 12345, + "cardType": "CREDIT", + "cardNo": "1111-2222-3333-4444", + "amount": 50000 +} + +### + +### ๊ฒฐ์ œ ์ฝœ๋ฐฑ +POST {{commerce-api}}/api/v1/payments/callback +Content-Type: application/json + +{ + "transactionKey": "tx_20251205_0001", + "orderId": "12345", + "status": "SUCCESS", + "reason": null +} + +### \ No newline at end of file From 4d4261d4fdb20617e41f214de670e8d191ccb77a Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 11 Dec 2025 10:42:38 +0900 Subject: [PATCH 129/164] =?UTF-8?q?feature:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=EB=B0=8F=20=ED=95=A0=EC=9D=B8=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ํ• ์ธ ์ •๋ณด๋ฅผ ๋‹ด๊ธฐ ์œ„ํ•œ `CouponDiscountResult` ๋ ˆ์ฝ”๋“œ ๋„์ž… - ์ฃผ๋ฌธ ์‹œ ์ฟ ํฐ ์‚ฌ์šฉ์„ ์œ„ํ•ด `OrderCommand`์— `couponId` ํ•„๋“œ ์ถ”๊ฐ€ - ์ฟ ํฐ ๊ฒ€์ฆยท์‚ฌ์šฉยทํ• ์ธ ๊ธˆ์•ก ๊ณ„์‚ฐ์„ ์ฒ˜๋ฆฌํ•˜๋Š” `useCouponAndCalculateDiscount` ๋ฉ”์„œ๋“œ๋ฅผ `CouponService`์— ์ถ”๊ฐ€ --- .../domain/coupon/CouponDiscountResult.java | 8 ++++++++ .../loopers/domain/coupon/CouponService.java | 20 +++++++++++++++++++ .../loopers/domain/order/OrderCommand.java | 1 + 3 files changed, 29 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java new file mode 100644 index 000000000..248c47e96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java @@ -0,0 +1,8 @@ +package com.loopers.domain.coupon; + +public record CouponDiscountResult( + long discountAmount, + Long usedCouponId +) { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java index fb200e9b6..b45c0b46d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -2,6 +2,7 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -16,4 +17,23 @@ public Coupon getCoupon(Long id) { return couponRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + @Transactional + public CouponDiscountResult useCouponAndCalculateDiscount( + Long couponId, + long totalOrderAmount + ) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + try { + coupon.use(); + } catch (CoreException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฏธ ์‚ฌ์šฉ๋˜์—ˆ๊ฑฐ๋‚˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."); + } + + long discountAmount = coupon.calculateDiscountAmount(totalOrderAmount); + + return new CouponDiscountResult(discountAmount, couponId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java index 8ac362b47..3ef1607cf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java @@ -6,6 +6,7 @@ public class OrderCommand { public record PlaceOrder( Long userId, + Long couponId, List items ) { From 3c24ed2cae369c2f925b4b5b315c133ecaa473a4 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 11 Dec 2025 14:07:07 +0900 Subject: [PATCH 130/164] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EB=B0=A9=EC=8B=9D=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=84=9C=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 - `OrderCommand`์— ๊ฒฐ์ œ ๋ฐฉ์‹(`paymentType`), ์นด๋“œ ์„ธ๋ถ€์ •๋ณด ์ถ”๊ฐ€ - `PaymentProcessor` ์ธํ„ฐํŽ˜์ด์Šค ๋ฐ ๊ตฌํ˜„์ฒด(PG/ํฌ์ธํŠธ) ์ถ”๊ฐ€ - ๊ฒฐ์ œ ๋ฐฉ์‹์— ๋”ฐ๋ฅธ ์ฒ˜๋ฆฌ ๋กœ์ง ๋ถ„๋ฆฌ ๋ฐ ํ†ตํ•ฉ - ์—”ํ‹ฐํ‹ฐ ๋ฐ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ˆ˜์ •์œผ๋กœ ๊ฒฐ์ œ ๋ฐฉ์‹ ๊ฒ€์ฆ ๋ฐ ํ…Œ์ŠคํŠธ ๊ฐ•ํ™” --- .../application/order/OrderFacade.java | 32 ++++++++- .../payment/PgPaymentProcessor.java | 66 +++++++++++++++++++ .../payment/PointPaymentProcessor.java | 37 +++++++++++ .../loopers/domain/order/OrderCommand.java | 14 +++- .../com/loopers/domain/payment/Payment.java | 21 +++--- .../domain/payment/PaymentProcessor.java | 13 ++++ .../domain/payment/PaymentService.java | 19 ++++++ .../loopers/domain/payment/PaymentType.java | 5 ++ .../interfaces/api/order/OrderV1Dto.java | 14 +++- .../order/OrderConcurrencyTest.java | 24 +++++-- .../order/OrderFacadeIntegrationTest.java | 32 +++++++-- 11 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentProcessor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PointPaymentProcessor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentProcessor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentType.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 62edaab5e..872ebdac5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,10 +1,14 @@ package com.loopers.application.order; +import com.loopers.domain.coupon.CouponDiscountResult; +import com.loopers.domain.coupon.CouponService; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderCommand.Item; import com.loopers.domain.order.OrderCommand.PlaceOrder; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentProcessor; import com.loopers.domain.point.PointService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; @@ -28,6 +32,8 @@ public class OrderFacade { private final UserService userService; private final OrderService orderService; private final PointService pointService; + private final CouponService couponService; + private final List paymentProcessors; @Transactional public OrderInfo placeOrder(PlaceOrder command) { @@ -45,8 +51,32 @@ public OrderInfo placeOrder(PlaceOrder command) { Order order = orderService.createOrder(user.getId(), orderItems); long totalAmount = order.getTotalAmount().getValue(); + long finalPaymentAmount = totalAmount; + + if (command.couponId() != null) { + CouponDiscountResult discountResult = couponService.useCouponAndCalculateDiscount( + command.couponId(), + totalAmount + ); + + finalPaymentAmount -= discountResult.discountAmount(); + } + productService.deductStock(products, orderItems); - pointService.deductPoint(user, totalAmount); + + PaymentProcessor processor = paymentProcessors.stream() + .filter(p -> p.supports(command.paymentType())) // command์— paymentType ํ•„๋“œ ์ถ”๊ฐ€ ํ•„์š” + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.")); + + Map paymentDetails = command.getPaymentDetails(); + + Payment paymentResult = processor.process( + order.getId(), + user, + finalPaymentAmount, + paymentDetails + ); return OrderInfo.from(order); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentProcessor.java new file mode 100644 index 000000000..744fc3599 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentProcessor.java @@ -0,0 +1,66 @@ +package com.loopers.application.payment; + +import com.loopers.domain.money.Money; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentExecutor; +import com.loopers.domain.payment.PaymentProcessor; +import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.payment.PaymentType; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PgPaymentProcessor implements PaymentProcessor { + + private final PaymentService paymentService; + private final PaymentExecutor paymentExecutor; + + @Override + public Payment process(Long orderId, User user, long finalAmount, Map paymentDetails) { + + String cardTypeStr = (String) paymentDetails.get("cardType"); + String cardNo = (String) paymentDetails.get("cardNo"); + + if (cardTypeStr == null || cardNo == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "PG ๊ฒฐ์ œ์— ํ•„์š”ํ•œ ์นด๋“œ ์ •๋ณด๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + CardType cardType; + try { + cardType = CardType.valueOf(cardTypeStr.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์นด๋“œ ํƒ€์ž…์ž…๋‹ˆ๋‹ค: " + cardTypeStr); + } + + return paymentService.findValidPayment(orderId) + .orElseGet(() -> { + + Payment newPayment = paymentService.createPendingPayment(user.getId(), + orderId, + new Money(finalAmount), + cardType, + cardNo + ); + + try { + String pgTxnId = paymentExecutor.execute(newPayment); + paymentService.registerPgToken(newPayment, pgTxnId); + return newPayment; + } catch (Exception e) { + paymentService.failPayment(newPayment); + throw e; + } + }); + } + + @Override + public boolean supports(PaymentType paymentType) { + return paymentType == PaymentType.PG; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PointPaymentProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PointPaymentProcessor.java new file mode 100644 index 000000000..110a2e33e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PointPaymentProcessor.java @@ -0,0 +1,37 @@ +package com.loopers.application.payment; + +import static com.loopers.domain.user.QUser.user; + +import com.loopers.domain.money.Money; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentProcessor; +import com.loopers.domain.payment.PaymentService; +import com.loopers.domain.payment.PaymentType; +import com.loopers.domain.point.PointService; +import com.loopers.domain.user.User; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PointPaymentProcessor implements PaymentProcessor { + + private final PointService pointService; + private final PaymentService paymentService; + + @Override + public Payment process(Long orderId, User user, long finalAmount, Map paymentDetails) { + pointService.deductPoint(user, finalAmount); + + return paymentService.createPointPaymentAndComplete( + orderId, + user.getId(), + new Money(finalAmount)); + } + + @Override + public boolean supports(PaymentType paymentType) { + return paymentType == PaymentType.POINT; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java index 3ef1607cf..88d69d49d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCommand.java @@ -1,14 +1,26 @@ package com.loopers.domain.order; +import com.loopers.domain.payment.PaymentType; import java.util.List; +import java.util.Map; public class OrderCommand { public record PlaceOrder( Long userId, Long couponId, - List items + List items, + PaymentType paymentType, + String cardType, + String cardNo ) { + public Map getPaymentDetails() { + return Map.of( + "cardType", cardType, + "cardNo", cardNo + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java index 7554eeefa..2d3b81b8a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java @@ -33,10 +33,14 @@ public class Payment extends BaseEntity { private String transactionId;//๋ฉฑ๋“ฑํ‚ค @Enumerated(EnumType.STRING) - @Column(name = "card_type", nullable = false) + @Column(name = "payment_type", nullable = false) + private PaymentType paymentType; + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = true) private CardType cardType; - @Column(name = "card_no", nullable = false) + @Column(name = "card_no", nullable = true) private String cardNo; @Embedded @@ -50,12 +54,13 @@ public class Payment extends BaseEntity { @Column(name = "pg_txn_id") private String pgTxnId; - public Payment(Long orderId, Long userId, Money amount, CardType cardType, String cardNo) { - validateConstructor(orderId, userId, amount, cardNo); + public Payment(Long orderId, Long userId, Money amount, PaymentType paymentType, CardType cardType, String cardNo) { + validateConstructor(orderId, userId, amount, paymentType); this.orderId = orderId; this.userId = userId; this.amount = amount; + this.paymentType = paymentType; this.cardType = cardType; this.cardNo = cardNo; this.status = PaymentStatus.READY; @@ -63,18 +68,18 @@ public Payment(Long orderId, Long userId, Money amount, CardType cardType, Strin this.transactionId = UUID.randomUUID().toString(); } - private void validateConstructor(Long orderId, Long userId, Money amount, String cardNo) { + private void validateConstructor(Long orderId, Long userId, Money amount, PaymentType paymentType) { if (orderId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } if (userId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉ์ž ์ •๋ณด๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } - if (amount == null) { + if (amount == null || amount.getValue() <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "๊ฒฐ์ œ ๊ธˆ์•ก์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } - if (!StringUtils.hasText(cardNo)) { - throw new CoreException(ErrorType.BAD_REQUEST, "์นด๋“œ ๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + if (paymentType == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฒฐ์ œ ๋ฐฉ์‹์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentProcessor.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentProcessor.java new file mode 100644 index 000000000..e2bcd4151 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentProcessor.java @@ -0,0 +1,13 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.user.User; +import java.util.Map; + +public interface PaymentProcessor { + + Payment process(Long orderId, User user, long finalAmount, Map paymentDetails); + + boolean supports(PaymentType paymentType); +} + + diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java index b5551cf7d..8902cad19 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java @@ -26,12 +26,31 @@ public Payment createPendingPayment(Long userId, Long orderId, Money amount, Car orderId, userId, amount, + PaymentType.PG, cardType, cardNo ); return paymentRepository.save(payment); } + @Transactional + public Payment createPointPaymentAndComplete(Long userId, Long orderId, Money amount) { + + Payment payment = new Payment( + orderId, + userId, + amount, + PaymentType.POINT, + null, + null + ); + paymentRepository.save(payment); + + completePayment(payment); + + return payment; + } + @Transactional public void registerPgToken(Payment payment, String pgTxnId) { payment.setPgTxnId(pgTxnId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentType.java new file mode 100644 index 000000000..c50f9b74e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentType.java @@ -0,0 +1,5 @@ +package com.loopers.domain.payment; + +public enum PaymentType { + POINT, PG +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 96ee350e1..5e116eb60 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -2,6 +2,7 @@ import com.loopers.application.order.OrderInfo; import com.loopers.domain.order.OrderCommand; +import com.loopers.domain.payment.PaymentType; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -10,7 +11,12 @@ public class OrderV1Dto { public record OrderRequest( @NotEmpty(message = "์ฃผ๋ฌธ ์ƒํ’ˆ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") - List items + List items, + Long couponId, + @NotNull(message = "๊ฒฐ์ œ ๋ฐฉ์‹์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + PaymentType paymentType, + String cardType, + String cardNo ) { public OrderCommand.PlaceOrder toCommand(Long userId) { @@ -20,7 +26,11 @@ public OrderCommand.PlaceOrder toCommand(Long userId) { return new OrderCommand.PlaceOrder( userId, - commandItems + couponId, + commandItems, + paymentType, + cardType, + cardNo ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java index f6618c9f6..b44439b04 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderConcurrencyTest.java @@ -5,6 +5,7 @@ import com.loopers.domain.money.Money; import com.loopers.domain.order.OrderCommand.Item; import com.loopers.domain.order.OrderCommand.PlaceOrder; +import com.loopers.domain.payment.PaymentType; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.user.User; @@ -46,6 +47,17 @@ void tearDown() { productJpaRepository.deleteAll(); } + private PlaceOrder createPlaceOrderCommand(Long userId, List items) { + return new PlaceOrder( + userId, + null, + items, + PaymentType.PG, + "KB", + "1234-5678-9012-3456" + ); + } + @Nested @DisplayName("ํฌ์ธํŠธ ๋™์‹œ์„ฑ (๋น„๊ด€์  ๋ฝ)") class PointConcurrency { @@ -82,8 +94,10 @@ void point_deduction_concurrency_test() { .mapToObj(i -> CompletableFuture.runAsync(() -> { try { Product targetProduct = distinctProducts.get(i); - PlaceOrder command = new PlaceOrder(savedUser.getId(), - List.of(new Item(targetProduct.getId(), 1))); + PlaceOrder command = createPlaceOrderCommand( + savedUser.getId(), + List.of(new Item(targetProduct.getId(), 1)) + ); orderFacade.placeOrder(command); @@ -141,8 +155,10 @@ void stock_deduction_concurrency_test() { List> futures = IntStream.range(0, threadCount) .mapToObj(i -> CompletableFuture.runAsync(() -> { try { - PlaceOrder command = new PlaceOrder(multiUsers.get(i).getId(), - List.of(new Item(savedProduct.getId(), 1))); + PlaceOrder command = createPlaceOrderCommand( + multiUsers.get(i).getId(), + List.of(new Item(savedProduct.getId(), 1)) + ); orderFacade.placeOrder(command); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index a16c4038a..5b3733a38 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -7,6 +7,7 @@ import com.loopers.domain.money.Money; import com.loopers.domain.order.OrderCommand; import com.loopers.domain.order.OrderCommand.Item; +import com.loopers.domain.payment.PaymentType; import com.loopers.domain.point.Point; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; @@ -44,6 +45,28 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } + private OrderCommand.PlaceOrder createPlaceOrderCommand(Long userId, List items) { + return new OrderCommand.PlaceOrder( + userId, + null, + items, + PaymentType.PG, + "KB", + "1234-5678-9012-3456" + ); + } + + private OrderCommand.PlaceOrder createPlaceOrderCommandWithCoupon(Long userId, Long couponId, List items) { + return new OrderCommand.PlaceOrder( + userId, + couponId, + items, + PaymentType.PG, + "KB", + "1234-5678-9012-3456" + ); + } + @DisplayName("์ƒํ’ˆ ์ฃผ๋ฌธ์‹œ") @Nested class PlaceOrder { @@ -59,7 +82,7 @@ void placeOrder_success() { Product saveProduct = productRepository.save(product); Item itemCommand = new Item(saveProduct.getId(), 3); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(savedUser.getId(), List.of(itemCommand)); + OrderCommand.PlaceOrder command = createPlaceOrderCommand(savedUser.getId(), List.of(itemCommand)); // act OrderInfo result = orderFacade.placeOrder(command); @@ -75,6 +98,7 @@ void placeOrder_success() { Product updatedProduct = productRepository.findById(saveProduct.getId()).orElseThrow(); assertThat(updatedProduct.getStock()).isEqualTo(7); + //pg๊ฒฐ์ œ์‹œ๋Š” ํฌ์ธํŠธ ์ฐจ๊ฐx -> ์ถ”ํ›„ ์ˆ˜์ • ์˜ˆ์ • User updatedUser = userRepository.findByUserId("userA").orElseThrow(); assertThat(updatedUser.getPoint().getAmount()).isEqualTo(40000L); } @@ -90,7 +114,7 @@ void placeOrder_fail_insufficient_stock() { ); Item itemCommand = new Item(product.getId(), 3); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + OrderCommand.PlaceOrder command = createPlaceOrderCommand(user.getId(), List.of(itemCommand)); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -111,7 +135,7 @@ void placeOrder_fail_insufficient_point() { ); Item itemCommand = new Item(product.getId(), 1); - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + OrderCommand.PlaceOrder command = createPlaceOrderCommand(user.getId(), List.of(itemCommand)); // act & assert CoreException exception = assertThrows(CoreException.class, () -> { @@ -134,7 +158,7 @@ void placeOrder_transaction_rollback_test() { ); Item itemCommand = new Item(product.getId(), 1); // 1๊ฐœ ์ฃผ๋ฌธ ์‹œ๋„ - OrderCommand.PlaceOrder command = new OrderCommand.PlaceOrder(user.getId(), List.of(itemCommand)); + OrderCommand.PlaceOrder command = createPlaceOrderCommand(user.getId(), List.of(itemCommand)); // act assertThrows(CoreException.class, () -> { From b3db4ed8f328edf9b81b9b1dd368993ee091ba70 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 11 Dec 2025 16:15:44 +0900 Subject: [PATCH 131/164] =?UTF-8?q?refator:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC(=ED=95=A0=EC=9D=B8=EC=9C=A8=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20+=20=EC=82=AC=EC=9A=A9=EC=99=84=EB=A3=8C=EC=B2=98?= =?UTF-8?q?=EB=A6=AC)=20->=20=EA=B0=81=EA=B0=81=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=ED=99=95=EC=9D=B8=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=BF=A0=ED=8F=B0=20=EC=83=81=ED=83=9C=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 --- .../application/order/OrderFacade.java | 9 ++---- .../com/loopers/domain/coupon/Coupon.java | 24 ++++++++++++++ .../domain/coupon/CouponDiscountResult.java | 8 ----- .../loopers/domain/coupon/CouponService.java | 31 ++++++++++++++----- .../loopers/domain/coupon/CouponStatus.java | 8 +++++ 5 files changed, 58 insertions(+), 22 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 872ebdac5..daeb13440 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,6 +1,5 @@ package com.loopers.application.order; -import com.loopers.domain.coupon.CouponDiscountResult; import com.loopers.domain.coupon.CouponService; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderCommand.Item; @@ -9,7 +8,6 @@ import com.loopers.domain.order.OrderService; import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentProcessor; -import com.loopers.domain.point.PointService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.User; @@ -31,7 +29,6 @@ public class OrderFacade { private final ProductService productService; private final UserService userService; private final OrderService orderService; - private final PointService pointService; private final CouponService couponService; private final List paymentProcessors; @@ -54,18 +51,18 @@ public OrderInfo placeOrder(PlaceOrder command) { long finalPaymentAmount = totalAmount; if (command.couponId() != null) { - CouponDiscountResult discountResult = couponService.useCouponAndCalculateDiscount( + long disCountAmount = couponService.calculateDiscountAmount( command.couponId(), totalAmount ); - finalPaymentAmount -= discountResult.discountAmount(); + finalPaymentAmount -= disCountAmount; } productService.deductStock(products, orderItems); PaymentProcessor processor = paymentProcessors.stream() - .filter(p -> p.supports(command.paymentType())) // command์— paymentType ํ•„๋“œ ์ถ”๊ฐ€ ํ•„์š” + .filter(p -> p.supports(command.paymentType())) .findFirst() .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java index 42dcd3e6f..b4158d426 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -27,6 +27,10 @@ public class Coupon extends BaseEntity { private boolean used; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private CouponStatus status; + public Coupon(long userId, CouponType type, long discountValue) { if (type == CouponType.PERCENTAGE && (discountValue < 0 || discountValue > 100)) { throw new CoreException(ErrorType.BAD_REQUEST, "ํ• ์ธ์œจ์€ 0~100% ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."); @@ -52,4 +56,24 @@ public void use() { this.used = true; } + public void reserve() { + if (this.status != CouponStatus.ISSUED) { + throw new CoreException(ErrorType.BAD_REQUEST, + "ํ˜„์žฌ ์ƒํƒœ(" + this.status + ")์—์„œ๋Š” ์ฟ ํฐ์„ ์˜ˆ์•ฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + this.status = CouponStatus.RESERVED; + } + + public void confirmUse() { + if (this.status != CouponStatus.RESERVED) { + throw new CoreException(ErrorType.BAD_REQUEST, + "์˜ˆ์•ฝ ์ƒํƒœ๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ ์‚ฌ์šฉ์„ ํ™•์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + this.status); + } + this.status = CouponStatus.USED; + } + + public boolean canUse() { + return this.status == CouponStatus.ISSUED; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java deleted file mode 100644 index 248c47e96..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponDiscountResult.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.loopers.domain.coupon; - -public record CouponDiscountResult( - long discountAmount, - Long usedCouponId -) { - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java index b45c0b46d..5e683cfe9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java @@ -2,9 +2,9 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -18,22 +18,37 @@ public Coupon getCoupon(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } - @Transactional - public CouponDiscountResult useCouponAndCalculateDiscount( + @Transactional(readOnly = true) + public long calculateDiscountAmount( Long couponId, long totalOrderAmount ) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + if (!coupon.canUse()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."); + } + + return coupon.calculateDiscountAmount(totalOrderAmount); + } + + @Transactional + public void reserveCoupon(Long couponId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + try { - coupon.use(); + coupon.reserve(); } catch (CoreException e) { - throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฏธ ์‚ฌ์šฉ๋˜์—ˆ๊ฑฐ๋‚˜ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†๋Š” ์ฟ ํฐ์ž…๋‹ˆ๋‹ค."); + throw new CoreException(ErrorType.BAD_REQUEST, "์ฟ ํฐ ์˜ˆ์•ฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); } + } - long discountAmount = coupon.calculateDiscountAmount(totalOrderAmount); - - return new CouponDiscountResult(discountAmount, couponId); + @Transactional + public void confirmCouponUsage(Long couponId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฟ ํฐ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + coupon.confirmUse(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java new file mode 100644 index 000000000..1b134b397 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponStatus.java @@ -0,0 +1,8 @@ +package com.loopers.domain.coupon; + +public enum CouponStatus { + ISSUED, + RESERVED, + USED, + CANCELED +} From 06fb841448916499a13c0ef5a37e9bd2a8a824a2 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 12 Dec 2025 11:36:57 +0900 Subject: [PATCH 132/164] =?UTF-8?q?feature:=20=EC=A3=BC=EB=AC=B8=EA=B3=BC?= =?UTF-8?q?=20=EA=B2=B0=EC=A0=9C=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `OrderStatus` ์ถ”๊ฐ€๋กœ ์ฃผ๋ฌธ ์ƒํƒœ ๊ด€๋ฆฌ ๊ฐ•ํ™” - ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ(`OrderCreatedEvent`) ๋ฐ ๊ด€๋ จ ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ๊ตฌํ˜„ - ๊ฒฐ์ œ ๋„๋ฉ”์ธ์—์„œ ์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ๋กœ์ง ์ถ”๊ฐ€ - ๊ฒฐ์ œ ์„ฑ๊ณต/์‹คํŒจ ์‹œ ์ฟ ํฐ ์‚ฌ์šฉ, ๋ฐ์ดํ„ฐ ํ”Œ๋žซํผ ์—ฐ๋™ ๋“ฑ ๋น„๋™๊ธฐ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ํ™œ์„ฑํ™” - `PaymentProcessor`๋ฅผ ํ†ตํ•œ ๊ฒฐ์ œ ๋ฐฉ์‹ ํ™•์žฅ ๊ฐ€๋Šฅ์„ฑ ๊ณ ๋ คํ•œ ์„ค๊ณ„ - ํ…Œ์ŠคํŠธ ๋ฐ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ถ”๊ฐ€/๊ฐœ์„  --- .../coupon/CouponUsageEventListener.java | 24 +++++++ .../DataPlatformEventListener.java | 22 ++++++ .../application/order/OrderCreatedEvent.java | 14 ++++ .../application/order/OrderFacade.java | 23 +++--- .../application/payment/PaymentEvent.java | 22 ++++++ .../payment/PgPaymentEventListener.java | 70 +++++++++++++++++++ .../point/PointPaymentEventListener.java | 69 ++++++++++++++++++ .../dataplatform/DataPlatformGateway.java | 6 ++ .../java/com/loopers/domain/order/Order.java | 14 ++++ .../loopers/domain/order/OrderService.java | 15 ++++ .../com/loopers/domain/order/OrderStatus.java | 9 +++ .../DummyDataPlatformGateway.java | 18 +++++ 12 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/dataplatform/DataPlatformEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/dataplatform/DataPlatformGateway.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DummyDataPlatformGateway.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java new file mode 100644 index 000000000..7d2160d85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java @@ -0,0 +1,24 @@ +package com.loopers.application.coupon; + +import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent; +import com.loopers.domain.coupon.CouponService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CouponUsageEventListener { + private final CouponService couponService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional + public void handlePaymentCompletedEvent(PaymentCompletedEvent event) { + + if (event.couponId() != null) { + couponService.confirmCouponUsage(event.couponId()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/dataplatform/DataPlatformEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/dataplatform/DataPlatformEventListener.java new file mode 100644 index 000000000..6ff252fdf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/dataplatform/DataPlatformEventListener.java @@ -0,0 +1,22 @@ +package com.loopers.application.dataplatform; + +import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent; +import com.loopers.domain.dataplatform.DataPlatformGateway; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class DataPlatformEventListener { + + private final DataPlatformGateway dataPlatformGateway; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreatedEvent(PaymentCompletedEvent event) { + dataPlatformGateway.sendPaymentData(event.orderId(), event.paymentId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java new file mode 100644 index 000000000..0f3168ada --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java @@ -0,0 +1,14 @@ +package com.loopers.application.order; + +import com.loopers.domain.payment.PaymentType; +import com.loopers.domain.user.User; + +public record OrderCreatedEvent( + Long orderId, + User user, + long finalAmount, + PaymentType paymentType, + String cardType, + String cardNo, + Long couponId +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index daeb13440..3871cac77 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -6,8 +6,6 @@ import com.loopers.domain.order.OrderCommand.PlaceOrder; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; -import com.loopers.domain.payment.Payment; -import com.loopers.domain.payment.PaymentProcessor; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.User; @@ -18,6 +16,7 @@ import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -30,7 +29,7 @@ public class OrderFacade { private final UserService userService; private final OrderService orderService; private final CouponService couponService; - private final List paymentProcessors; + private final ApplicationEventPublisher eventPublisher; @Transactional public OrderInfo placeOrder(PlaceOrder command) { @@ -59,22 +58,22 @@ public OrderInfo placeOrder(PlaceOrder command) { finalPaymentAmount -= disCountAmount; } - productService.deductStock(products, orderItems); - - PaymentProcessor processor = paymentProcessors.stream() - .filter(p -> p.supports(command.paymentType())) - .findFirst() - .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค.")); + couponService.reserveCoupon(command.couponId()); - Map paymentDetails = command.getPaymentDetails(); + productService.deductStock(products, orderItems); - Payment paymentResult = processor.process( + OrderCreatedEvent orderEvent = new OrderCreatedEvent( order.getId(), user, finalPaymentAmount, - paymentDetails + command.paymentType(), + command.cardType(), + command.cardNo(), + command.couponId() ); + eventPublisher.publishEvent(orderEvent); + return OrderInfo.from(order); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java new file mode 100644 index 000000000..92c93ffea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java @@ -0,0 +1,22 @@ +package com.loopers.application.payment; + +public class PaymentEvent { + public record PaymentRequestedEvent( + Long orderId, + Long paymentId, + Long couponId + ) {} + + public record PaymentCompletedEvent( + Long orderId, + Long paymentId, + boolean isSuccess, + Long couponId + ) {} + + public record PaymentRequestFailedEvent( + Long orderId, + Long couponId, + String errorMessage + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java new file mode 100644 index 000000000..9b602fe21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java @@ -0,0 +1,70 @@ +package com.loopers.application.payment; + +import com.loopers.application.order.OrderCreatedEvent; +import com.loopers.application.payment.PaymentEvent.PaymentRequestFailedEvent; +import com.loopers.application.payment.PaymentEvent.PaymentRequestedEvent; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentProcessor; +import com.loopers.domain.payment.PaymentType; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class PgPaymentEventListener { + + private final List paymentProcessors; + private final UserService userService; + private final ApplicationEventPublisher eventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional + public void handleOrderCreatedEvent(OrderCreatedEvent event) { + + if (event.paymentType() != PaymentType.PG) { + return; + } + + User user = userService.findById(event.user().getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์œ ์ € ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + PaymentProcessor processor = paymentProcessors.stream() + .filter(p -> p.supports(event.paymentType())) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "PG ๊ฒฐ์ œ ํ”„๋กœ์„ธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + Map paymentDetails = Map.of( + "cardType", event.cardType(), + "cardNo", event.cardNo() + ); + + try { + Payment payment = processor.process( + event.orderId(), + user, + event.finalAmount(), + paymentDetails + ); + + eventPublisher.publishEvent(new PaymentRequestedEvent( + event.orderId(), payment.getId(), event.couponId())); + + } catch (Exception e) { + eventPublisher.publishEvent(new PaymentRequestFailedEvent( + event.orderId(), + event.couponId(), + e.getMessage() + )); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java new file mode 100644 index 000000000..cedde870c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java @@ -0,0 +1,69 @@ +package com.loopers.application.point; + +import com.loopers.application.order.OrderCreatedEvent; +import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent; +import com.loopers.application.payment.PaymentEvent.PaymentRequestFailedEvent; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.Payment; +import com.loopers.domain.payment.PaymentProcessor; +import com.loopers.domain.payment.PaymentType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class PointPaymentEventListener { + private final List paymentProcessors; + private final OrderService orderService; + private final ApplicationEventPublisher eventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional + public void handleOrderCreatedEvent(OrderCreatedEvent event) { + + if (event.paymentType() != PaymentType.POINT) { + return; + } + + PaymentProcessor processor = paymentProcessors.stream() + .filter(p -> p.supports(event.paymentType())) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ ๊ฒฐ์ œ ํ”„๋กœ์„ธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + try { + Payment payment = processor.process( + event.orderId(), + event.user(), + event.finalAmount(), + Map.of() + ); + + orderService.updateOrderStatus(event.orderId(), OrderStatus.PAYMENT_COMPLETED); + + eventPublisher.publishEvent(new PaymentCompletedEvent( + event.orderId(), + payment.getId(), + true, + event.couponId() + )); + + } catch (CoreException e) { + orderService.failPayment(event.orderId()); + eventPublisher.publishEvent(new PaymentRequestFailedEvent( + event.orderId(), + event.couponId(), + e.getMessage() + )); + + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/dataplatform/DataPlatformGateway.java b/apps/commerce-api/src/main/java/com/loopers/domain/dataplatform/DataPlatformGateway.java new file mode 100644 index 000000000..942787d05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/dataplatform/DataPlatformGateway.java @@ -0,0 +1,6 @@ +package com.loopers.domain.dataplatform; + +public interface DataPlatformGateway { + + void sendPaymentData(Long orderId, Long paymentId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 8cde47ca9..9cb5a05b8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -10,6 +10,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; @@ -25,6 +27,10 @@ public class Order extends BaseEntity { @Column(name = "ref_user_id", nullable = false) private Long userId; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + @Embedded @AttributeOverride(name = "value", column = @Column(name = "total_amount")) private Money totalAmount; @@ -50,6 +56,14 @@ public Long getUserId() { return userId; } + public OrderStatus getStatus() { + return status; + } + + public void updateStatus(OrderStatus newStatus) { + this.status = newStatus; + } + public void addOrderItem(Product product, int quantity) { if (product == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 99b129f30..77a48a69b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -5,9 +5,11 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component +@Transactional public class OrderService { private final OrderRepository orderRepository; @@ -25,4 +27,17 @@ public List getOrders(Long userId) { public Order getOrder(Long id) { return orderRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); } + + public void updateOrderStatus(Long orderId, OrderStatus newStatus) { + Order order = getOrder(orderId); + order.updateStatus(newStatus); + } + public void failPayment(Long orderId) { + Order order = getOrder(orderId); + + if (order.getStatus() != OrderStatus.PAYMENT_COMPLETED) { + order.updateStatus(OrderStatus.PAYMENT_FAILED); + } + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..918abaeae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,9 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + PENDING_PAYMENT, + PAYMENT_REQUESTED, + PAYMENT_COMPLETED, + PAYMENT_FAILED, + CANCELED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DummyDataPlatformGateway.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DummyDataPlatformGateway.java new file mode 100644 index 000000000..d967b3ff1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/dataplatform/DummyDataPlatformGateway.java @@ -0,0 +1,18 @@ +package com.loopers.infrastructure.dataplatform; + +import com.loopers.domain.dataplatform.DataPlatformGateway; +import org.springframework.stereotype.Component; + +@Component +public class DummyDataPlatformGateway implements DataPlatformGateway { + + @Override + public void sendPaymentData(Long orderId, Long paymentId) { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + } +} From 60ebf6d02c4a08e8c78aced9885b2c7361c31149 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 12 Dec 2025 14:03:40 +0900 Subject: [PATCH 133/164] =?UTF-8?q?feature:=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `LikeCreatedEvent`๋ฅผ ํ†ตํ•ด ์ข‹์•„์š” ์ด๋ฒคํŠธ ์ˆ˜์ง‘ ๋ฐ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ๋„์ž… - `FailedEvent` ๋ฐ ๊ด€๋ จ ์ €์žฅ์†Œ/์Šค์ผ€์ค„๋Ÿฌ ์ถ”๊ฐ€๋กœ ์‹คํŒจํ•œ ์ด๋ฒคํŠธ ๊ด€๋ฆฌ ๋ฐ ์žฌ์‹œ๋„ ๊ฐ€๋Šฅ - `LikeCountAggregateListener`๋กœ ์ข‹์•„์š” ์ˆ˜ ์ƒํƒœ ๋น„๋™๊ธฐ ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ - `DeadLetterQueueProcessor`๋ฅผ ํ†ตํ•ด ์‹คํŒจ ์ด๋ฒคํŠธ ์ฃผ๊ธฐ์  ์žฌ ์ฒ˜๋ฆฌ - ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ ๋ฐ ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ๋กœ ์„œ๋น„์Šค ์•ˆ์ •์„ฑ ๊ฐ•ํ™” --- .../event/DeadLetterQueueProcessor.java | 47 +++++++++++++++ .../event/FailedEventScheduler.java | 8 +++ .../application/event/FailedEventStore.java | 44 ++++++++++++++ .../application/like/LikeCreatedEvent.java | 10 ++++ .../loopers/application/like/LikeFacade.java | 58 +++++-------------- .../product/LikeCountAggregateListener.java | 48 +++++++++++++++ .../com/loopers/domain/event/DomainEvent.java | 5 ++ .../com/loopers/domain/event/FailedEvent.java | 42 ++++++++++++++ .../event/FailedEventRepository.java | 12 ++++ 9 files changed, 229 insertions(+), 45 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventScheduler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/FailedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/FailedEventRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java new file mode 100644 index 000000000..df94b6165 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java @@ -0,0 +1,47 @@ +package com.loopers.application.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.DomainEvent; +import com.loopers.domain.event.FailedEvent; +import com.loopers.infrastructure.event.FailedEventRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class DeadLetterQueueProcessor { + + private final FailedEventRepository failedEventRepository; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + private static final int MAX_RETRY_COUNT = 5; + + @Scheduled(fixedRate = 300000) + @Transactional + public void retryFailedEvents() { + + List eventsToRetry = failedEventRepository.findByRetryCountLessThan(MAX_RETRY_COUNT); + + if (eventsToRetry.isEmpty()) return; + + for (FailedEvent failedEvent : eventsToRetry) { + try { + Class eventClass = Class.forName(failedEvent.getEventType()); + DomainEvent originalEvent = (DomainEvent) objectMapper.readValue( + failedEvent.getEventPayload(), eventClass); + + eventPublisher.publishEvent(originalEvent); + failedEventRepository.delete(failedEvent); + + } catch (Exception e) { + failedEvent.incrementRetryCount(); + failedEventRepository.save(failedEvent); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventScheduler.java new file mode 100644 index 000000000..532e28fe2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventScheduler.java @@ -0,0 +1,8 @@ +package com.loopers.application.event; + +import com.loopers.domain.event.DomainEvent; + +public interface FailedEventScheduler { + + void scheduleRetry(T event, String reason); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java b/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java new file mode 100644 index 000000000..f5dd2e838 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java @@ -0,0 +1,44 @@ +package com.loopers.application.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.DomainEvent; +import com.loopers.domain.event.FailedEvent; +import com.loopers.infrastructure.event.FailedEventRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class FailedEventStore implements FailedEventScheduler { + + private final FailedEventRepository failedEventRepository; + private final ObjectMapper objectMapper; + + @Transactional + @Override + public void scheduleRetry(T event, String reason) { + String payload; + + try { + payload = objectMapper.writeValueAsString(event); + } catch (JsonProcessingException e) { + return; + } + + FailedEvent failedEvent = new FailedEvent( + event.getClass().getName(), + payload, + reason, + 0, + LocalDateTime.now() + ); + + try { + failedEventRepository.save(failedEvent); + } catch (Exception e) { + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java new file mode 100644 index 000000000..88c242010 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java @@ -0,0 +1,10 @@ +package com.loopers.application.like; + +import com.loopers.domain.event.DomainEvent; + +public record LikeCreatedEvent( + long productId, + int increment +) implements DomainEvent { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 560df1cd0..3f57b500a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -6,9 +6,9 @@ import com.loopers.domain.product.ProductService; import java.util.Optional; import lombok.RequiredArgsConstructor; -import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; -import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -16,65 +16,33 @@ public class LikeFacade { private final ProductService productService; private final LikeService likeService; - private final TransactionTemplate transactionTemplate; - - private static final int RETRY_COUNT = 30; + private final ApplicationEventPublisher eventPublisher; + @Transactional public LikeInfo like(long userId, long productId) { Optional existingLike = likeService.findLike(userId, productId); + Product product = productService.getProduct(productId); if (existingLike.isPresent()) { - Product product = productService.getProduct(productId); return LikeInfo.from(existingLike.get(), product.getLikeCount()); } - for (int i = 0; i < RETRY_COUNT; i++) { - try { - return transactionTemplate.execute(status -> { - Like newLike = likeService.save(userId, productId); - int updatedLikeCount = productService.increaseLikeCount(productId); - return LikeInfo.from(newLike, updatedLikeCount); - }); + Like newLike = likeService.save(userId, productId); - } catch (ObjectOptimisticLockingFailureException e) { - if (i == RETRY_COUNT - 1) { - throw e; - } - sleep(50); - } - } - throw new IllegalStateException("์ข‹์•„์š” ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); + eventPublisher.publishEvent(new LikeCreatedEvent(productId, 1)); + + return LikeInfo.from(newLike, product.getLikeCount()); } + @Transactional public int unLike(long userId, long productId) { - for (int i = 0; i < RETRY_COUNT; i++) { - try { - - return transactionTemplate.execute(status -> { - likeService.unLike(userId, productId); + likeService.unLike(userId, productId); - return productService.decreaseLikeCount(productId); + eventPublisher.publishEvent(new LikeCreatedEvent(productId, -1)); - }); + return productService.getProduct(productId).getLikeCount(); - } catch (ObjectOptimisticLockingFailureException e) { - - if (i == RETRY_COUNT - 1) { - throw e; - } - sleep(50); - } - } - throw new IllegalStateException("์‹ซ์–ด์š” ์ฒ˜๋ฆฌ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์ดˆ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค."); } - private void sleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java new file mode 100644 index 000000000..34a5812bc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java @@ -0,0 +1,48 @@ +package com.loopers.application.product; + +import com.loopers.application.event.FailedEventStore; +import com.loopers.application.like.LikeCreatedEvent; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class LikeCountAggregateListener { + + private final ProductService productService; + private final FailedEventStore failedEventStore; + + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeCreatedEvent(LikeCreatedEvent event) { + + try { + performAggregation(event); + + } catch (ObjectOptimisticLockingFailureException e) { + + failedEventStore.scheduleRetry(event, "Optimistic Lock Conflict"); + + } catch (Exception e) { + + failedEventStore.scheduleRetry(event, "Unexpected Error: " + e.getMessage()); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void performAggregation(LikeCreatedEvent event) { + if (event.increment() > 0) { + productService.increaseLikeCount(event.productId()); + } else { + productService.decreaseLikeCount(event.productId()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEvent.java new file mode 100644 index 000000000..d5dd31a84 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/DomainEvent.java @@ -0,0 +1,5 @@ +package com.loopers.domain.event; + +public interface DomainEvent { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/FailedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/FailedEvent.java new file mode 100644 index 000000000..3ac900607 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/FailedEvent.java @@ -0,0 +1,42 @@ +package com.loopers.domain.event; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FailedEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String eventType; + + @Lob + private String eventPayload; + + private String failureReason; + private int retryCount; + private LocalDateTime createdAt; + + public FailedEvent(String eventType, String eventPayload, String failureReason, int retryCount, LocalDateTime createdAt) { + this.eventType = eventType; + this.eventPayload = eventPayload; + this.failureReason = failureReason; + this.retryCount = retryCount; + this.createdAt = createdAt; + } + + public void incrementRetryCount() { + this.retryCount++; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/FailedEventRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/FailedEventRepository.java new file mode 100644 index 000000000..36ad6e8e4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/FailedEventRepository.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.event; + +import com.loopers.domain.event.FailedEvent; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface FailedEventRepository extends JpaRepository { + + List findByRetryCountLessThan(int maxRetries); +} From 93ac95d0f205135ec705e0d813d3197822005cf2 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 12 Dec 2025 14:20:37 +0900 Subject: [PATCH 134/164] =?UTF-8?q?feature:=20DLQ=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `MonitoringService` ์ธํ„ฐํŽ˜์ด์Šค ๋ฐ `DummyMonitoringAdapter` ๊ตฌํ˜„์ฒด ์ถ”๊ฐ€ - DLQ ์ €์žฅ์†Œ์—์„œ ์ด๋ฒคํŠธ ์ €์žฅ ์„ฑ๊ณต ์‹œ ๋ฉ”ํŠธ๋ฆญ ์ฆ๊ฐ€ ๋กœ์ง ์ถ”๊ฐ€ - ์ด๋ฒคํŠธ ์ €์žฅ ์‹คํŒจ ์‹œ ์น˜๋ช…์  ์•Œ๋ฆผ ๋กœ์ง ์ ์šฉ (๋ชจ๋‹ˆํ„ฐ๋ง ์„œ๋น„์Šค ํ™œ์šฉ) --- .../application/event/FailedEventStore.java | 6 ++++++ .../port/DummyMonitoringAdapter.java | 18 ++++++++++++++++++ .../monitoring/port/MonitoringService.java | 8 ++++++++ 3 files changed, 32 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/DummyMonitoringAdapter.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/MonitoringService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java b/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java index f5dd2e838..a17b3382b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/FailedEventStore.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.monitoring.port.MonitoringService; import com.loopers.domain.event.DomainEvent; import com.loopers.domain.event.FailedEvent; import com.loopers.infrastructure.event.FailedEventRepository; @@ -16,6 +17,7 @@ public class FailedEventStore implements FailedEventScheduler { private final FailedEventRepository failedEventRepository; private final ObjectMapper objectMapper; + private final MonitoringService monitoringService; @Transactional @Override @@ -38,7 +40,11 @@ public void scheduleRetry(T event, String reason) { try { failedEventRepository.save(failedEvent); + monitoringService.incrementMetric("dlq.event_saved_total", "type:" + failedEvent.getEventType()); + } catch (Exception e) { + monitoringService.sendCriticalAlert( + "CRITICAL: DLQ ์ €์žฅ์†Œ ์žฅ์• . ์ด๋ฒคํŠธ ์œ ์‹ค ์œ„ํ—˜ ๋ฐœ์ƒ! ์‚ฌ์œ : " + e.getMessage(), e); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/DummyMonitoringAdapter.java b/apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/DummyMonitoringAdapter.java new file mode 100644 index 000000000..93a0ecfee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/DummyMonitoringAdapter.java @@ -0,0 +1,18 @@ +package com.loopers.application.monitoring.port; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class DummyMonitoringAdapter implements MonitoringService { + @Override + public void incrementMetric(String name, String tag) { + log.info("[METRIC_DUMMY] Increment | Name: {} | Tag: {}", name, tag); + } + + @Override + public void sendCriticalAlert(String message, Throwable t) { + log.error("[ALERT_DUMMY] CRITICAL ALERT: {}", message, t); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/MonitoringService.java b/apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/MonitoringService.java new file mode 100644 index 000000000..1e4a17310 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/monitoring/port/MonitoringService.java @@ -0,0 +1,8 @@ +package com.loopers.application.monitoring.port; + +public interface MonitoringService { + + void incrementMetric(String name, String tag); + + void sendCriticalAlert(String message, Throwable t); +} From 36aad30de7b4ca5ea3664e2101261571e403a5a5 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 12 Dec 2025 14:26:13 +0900 Subject: [PATCH 135/164] chore: add optimistic locking to Coupon entity with @Version field --- .../src/main/java/com/loopers/domain/coupon/Coupon.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java index b4158d426..a24a1e2b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java @@ -8,6 +8,7 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Table; +import jakarta.persistence.Version; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -16,6 +17,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Coupon extends BaseEntity { + @Version + private long version; + @Column(name = "ref_user_id", nullable = false) private long userId; From 1a3aa85ab9eabc37108cf2e6391df40c6463f043 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 12 Dec 2025 14:32:48 +0900 Subject: [PATCH 136/164] =?UTF-8?q?feature:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A9=94=EC=BB=A4=EB=8B=88=EC=A6=98=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 - ์žฌ์‹œ๋„ ์Šค์ผ€์ค„๋ง์„ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด `FailedEventStore` ๋„์ž…. - `CouponUsageEventListener`์— ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ์ถ”๊ฐ€ํ•˜์—ฌ ๋ฝ ์ถฉ๋Œ ๋ฐ ๊ธฐํƒ€ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์žฌ์‹œ๋„ ์ˆ˜ํ–‰. - ์ฟ ํฐ ์‚ฌ์šฉ ํ™•์ธ ํ”„๋กœ์„ธ์Šค์˜ ์•ˆ์ •์„ฑ์„ ๊ฐ•ํ™”. --- .../coupon/CouponUsageEventListener.java | 13 ++++++++++++- .../loopers/application/payment/PaymentEvent.java | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java index 7d2160d85..b580d2ac8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java @@ -1,8 +1,10 @@ package com.loopers.application.coupon; +import com.loopers.application.event.FailedEventStore; import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent; import com.loopers.domain.coupon.CouponService; import lombok.RequiredArgsConstructor; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; @@ -12,13 +14,22 @@ @RequiredArgsConstructor public class CouponUsageEventListener { private final CouponService couponService; + private final FailedEventStore failedEventStore; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional public void handlePaymentCompletedEvent(PaymentCompletedEvent event) { - if (event.couponId() != null) { + if (event.couponId() == null) return; + + try { couponService.confirmCouponUsage(event.couponId()); + + } catch (ObjectOptimisticLockingFailureException e) { + failedEventStore.scheduleRetry(event, "Coupon Lock Conflict on Confirmation"); + + } catch (Exception e) { + failedEventStore.scheduleRetry(event, "Coupon confirmation error: " + e.getMessage()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java index 92c93ffea..0a83e712c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentEvent.java @@ -1,5 +1,7 @@ package com.loopers.application.payment; +import com.loopers.domain.event.DomainEvent; + public class PaymentEvent { public record PaymentRequestedEvent( Long orderId, @@ -12,7 +14,7 @@ public record PaymentCompletedEvent( Long paymentId, boolean isSuccess, Long couponId - ) {} + ) implements DomainEvent {} public record PaymentRequestFailedEvent( Long orderId, From 74e354bb53fcd461f9f01715847c44d6e28d53f9 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 12 Dec 2025 15:36:38 +0900 Subject: [PATCH 137/164] =?UTF-8?q?feature:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=20=EC=B6=94=EC=A0=81=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `UserActionTrackEvent` ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€๋กœ ์‚ฌ์šฉ์ž ํ™œ๋™ ์ถ”์  ๊ธฐ๋ฐ˜ ๋„๋ฉ”์ธ ์„ค๊ณ„ ๋„์ž…. - `LikeActionTrackEvent` ๊ตฌํ˜„์ฒด ์ถ”๊ฐ€๋กœ ์ข‹์•„์š”/์ทจ์†Œ ์ด๋ฒคํŠธ ๋ฐ์ดํ„ฐ ์ถ”์  ๊ฐ€๋Šฅ. - `LikeFacade`์— ์‚ฌ์šฉ์ž ํ™œ๋™ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ๋กœ์ง ์ถ”๊ฐ€. - ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ์•„ํ‚คํ…์ฒ˜์™€ ๋ฐ์ดํ„ฐ ์ถ”์  ์—ฐ๊ณ„ ๊ฐ•ํ™”. --- .../event/DeadLetterQueueProcessor.java | 8 +++-- .../like/LikeActionTrackEvent.java | 30 +++++++++++++++++++ .../loopers/application/like/LikeFacade.java | 12 ++++++++ .../domain/event/UserActionTrackEvent.java | 11 +++++++ 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/UserActionTrackEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java index df94b6165..e7d29ea07 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java @@ -4,13 +4,15 @@ import com.loopers.domain.event.DomainEvent; import com.loopers.domain.event.FailedEvent; import com.loopers.infrastructure.event.FailedEventRepository; -import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Component @RequiredArgsConstructor public class DeadLetterQueueProcessor { @@ -22,7 +24,7 @@ public class DeadLetterQueueProcessor { private static final int MAX_RETRY_COUNT = 5; @Scheduled(fixedRate = 300000) - @Transactional + @Transactional public void retryFailedEvents() { List eventsToRetry = failedEventRepository.findByRetryCountLessThan(MAX_RETRY_COUNT); @@ -39,7 +41,7 @@ public void retryFailedEvents() { failedEventRepository.delete(failedEvent); } catch (Exception e) { - failedEvent.incrementRetryCount(); + failedEvent.incrementRetryCount(); failedEventRepository.save(failedEvent); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java new file mode 100644 index 000000000..80795684e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java @@ -0,0 +1,30 @@ +package com.loopers.application.like; + +import com.loopers.domain.event.UserActionTrackEvent; +import java.time.ZonedDateTime; +import java.util.Map; + +public record LikeActionTrackEvent( + Long userId, + Long productId, + String action, + + ZonedDateTime eventTime, + Map properties + +) implements UserActionTrackEvent { + + public LikeActionTrackEvent(Long userId, Long productId, String action) { + this(userId, productId, action, ZonedDateTime.now(), Map.of()); + } + + @Override + public String eventType() { + return "LIKE_ACTION"; + } + + @Override + public Map getProperties() { + return properties; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 3f57b500a..37930112c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -31,6 +31,12 @@ public LikeInfo like(long userId, long productId) { eventPublisher.publishEvent(new LikeCreatedEvent(productId, 1)); + eventPublisher.publishEvent(new LikeActionTrackEvent( + userId, + productId, + "LIKE" + )); + return LikeInfo.from(newLike, product.getLikeCount()); } @@ -41,6 +47,12 @@ public int unLike(long userId, long productId) { eventPublisher.publishEvent(new LikeCreatedEvent(productId, -1)); + eventPublisher.publishEvent(new LikeActionTrackEvent( + userId, + productId, + "UNLIKE" + )); + return productService.getProduct(productId).getLikeCount(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/UserActionTrackEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/UserActionTrackEvent.java new file mode 100644 index 000000000..b4d977ed3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/UserActionTrackEvent.java @@ -0,0 +1,11 @@ +package com.loopers.domain.event; + +import java.time.ZonedDateTime; +import java.util.Map; + +public interface UserActionTrackEvent { + Long userId(); + String eventType(); + ZonedDateTime eventTime(); + Map getProperties(); +} From 7cb28e5a55fb4e223acf3ac2d5d598e822f8aac2 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 18 Dec 2025 19:41:07 +0900 Subject: [PATCH 138/164] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=A0=88?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=ED=95=98?= =?UTF-8?q?=EC=97=AC=20LocalDateTime=20=E2=86=92=20ZonedDateTime=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20PaymentRepository=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=EC=84=9C=20Loc?= =?UTF-8?q?alDateTime=20=EB=8C=80=EC=8B=A0=20ZonedDateTime=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=EB=84=88?= =?UTF-8?q?=EC=9D=98=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=84?= =?UTF-8?q?=ED=8C=8C=20=EB=B0=A9=EC=8B=9D=EC=9D=84=20Propagation.REQUIRES?= =?UTF-8?q?=5FNEW=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/coupon/CouponUsageEventListener.java | 3 ++- .../loopers/application/payment/PaymentRecoveryScheduler.java | 4 ++-- .../loopers/application/payment/PgPaymentEventListener.java | 3 ++- .../loopers/application/point/PointPaymentEventListener.java | 3 ++- .../java/com/loopers/domain/payment/PaymentRepository.java | 4 ++-- .../loopers/infrastructure/payment/PaymentJpaRepository.java | 4 ++-- .../loopers/infrastructure/payment/PaymentRepositoryImpl.java | 4 ++-- 7 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java index b580d2ac8..03353ed6f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponUsageEventListener.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -17,7 +18,7 @@ public class CouponUsageEventListener { private final FailedEventStore failedEventStore; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handlePaymentCompletedEvent(PaymentCompletedEvent event) { if (event.couponId() == null) return; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java index 925d2f20e..3a0e46c83 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentRecoveryScheduler.java @@ -7,7 +7,7 @@ import com.loopers.infrastructure.pg.PgV1Dto.PgDetail; import com.loopers.infrastructure.pg.PgV1Dto.PgOrderResponse; import jakarta.transaction.Transactional; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; @@ -23,7 +23,7 @@ public class PaymentRecoveryScheduler { @Scheduled(fixedDelay = 60000) @Transactional public void recover() { - LocalDateTime timeLimit = LocalDateTime.now().minusMinutes(5); + ZonedDateTime timeLimit = ZonedDateTime.now().minusMinutes(5); List stuckPayments = paymentRepository.findAllByStatusAndCreatedAtBefore( PaymentStatus.READY, timeLimit ); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java index 9b602fe21..b189aab6b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -28,7 +29,7 @@ public class PgPaymentEventListener { private final ApplicationEventPublisher eventPublisher; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleOrderCreatedEvent(OrderCreatedEvent event) { if (event.paymentType() != PaymentType.PG) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java index cedde870c..8c9a04751 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -27,7 +28,7 @@ public class PointPaymentEventListener { private final ApplicationEventPublisher eventPublisher; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void handleOrderCreatedEvent(OrderCreatedEvent event) { if (event.paymentType() != PaymentType.POINT) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java index 3b334df34..383110af7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -1,6 +1,6 @@ package com.loopers.domain.payment; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; @@ -10,7 +10,7 @@ public interface PaymentRepository { Optional findByOrderId(Long id); - List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, LocalDateTime timeLimit); + List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, ZonedDateTime timeLimit); Optional findByPgTxnId(String pgTxnId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java index dd790fb32..37f87e8de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -2,14 +2,14 @@ import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentStatus; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PaymentJpaRepository extends JpaRepository { - List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, LocalDateTime createdAt); + List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, ZonedDateTime createdAt); Optional findByPgTxnId(String pgTxnId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java index c91562bb2..221c4374e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -3,7 +3,7 @@ import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentRepository; import com.loopers.domain.payment.PaymentStatus; -import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -27,7 +27,7 @@ public Optional findByOrderId(Long id) { @Override public List findAllByStatusAndCreatedAtBefore(PaymentStatus paymentStatus, - LocalDateTime timeLimit) { + ZonedDateTime timeLimit) { return paymentJpaRepository.findAllByStatusAndCreatedAtBefore(paymentStatus, timeLimit); } From 7bc7bf3ef6322514f4f0595b39b900854b3aa709 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 18 Dec 2025 19:43:12 +0900 Subject: [PATCH 139/164] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20JPA=C2=B7Redis=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20Kafka=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A0=95=EB=A6=AC=20-=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EC=86=8C=EC=8A=A4=20=EC=84=A4=EC=A0=95=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC(jpa.yml,=20redis.yml)=20=EC=82=AD=EC=A0=9C=20-=20Kafk?= =?UTF-8?q?aConfig=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=AA=85=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EC=88=98=EC=A0=95=20(confg=20=E2=86=92=20config)?= =?UTF-8?q?=20-=20build.gradle.kts=EC=97=90=20kafka=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20application.yml=20=EC=84=A4=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/commerce-api/build.gradle.kts | 2 ++ .../src/main/resources/application.yml | 33 ++++++++++++++----- apps/commerce-api/src/main/resources/jpa.yml | 19 ----------- .../commerce-api/src/main/resources/redis.yml | 7 ---- .../consumer/DemoKafkaConsumer.java | 2 +- .../{confg => config}/kafka/KafkaConfig.java | 2 +- 6 files changed, 28 insertions(+), 37 deletions(-) delete mode 100644 apps/commerce-api/src/main/resources/jpa.yml delete mode 100644 apps/commerce-api/src/main/resources/redis.yml rename modules/kafka/src/main/java/com/loopers/{confg => config}/kafka/KafkaConfig.java (99%) diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 906943a1d..89735dd30 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,10 +2,12 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) + // web implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator") diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 8b5957e23..2de09b65d 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -23,29 +23,44 @@ spring: - redis.yml - logging.yml - monitoring.yml + - kafka.yml + cloud: + openfeign: + client: + config: + default: + connectTimeout: 1000 + readTimeout: 1000 resilience4j: - # Retry retry: instances: - pgRetry: - max-attempts: 3 - wait-duration: 1000ms + pg-client: + max-attempts: 2 # 1๋ฒˆ๋งŒ ์žฌ์‹œ๋„ (์ด 2ํšŒ) + wait-duration: 500ms retry-exceptions: - java.io.IOException - - java.util.concurrent.TimeoutException - - java.net.ConnectException + - java.net.SocketTimeoutException + - feign.RetryableException + # ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์—๋Ÿฌ(400 ๋“ฑ)๋Š” ์žฌ์‹œ๋„ ํ•˜์ง€ ์•Š์Œ + ignore-exceptions: + - com.loopers.support.error.CoreException + - feign.FeignException.BadRequest - # CircuitBreaker circuitbreaker: configs: default: register-health-indicator: true instances: - simpleCircuitBreakerConfig: + pg-client: sliding-window-type: COUNT_BASED sliding-window-size: 10 - failure-rate-threshold: 50 + minimum-number-of-calls: 5 + # 500ms ์ด์ƒ ๊ฑธ๋ฆฌ๋ฉด '์Šฌ๋กœ์šฐ ์ฝœ'๋กœ ๊ฐ„์ฃผ (๋น ๋ฅธ ์ฐจ๋‹จ) + slow-call-duration-threshold: 500ms + slow-call-rate-threshold: 50 + + failure-rate-threshold: 40 wait-duration-in-open-state: 10s permitted-number-of-calls-in-half-open-state: 3 diff --git a/apps/commerce-api/src/main/resources/jpa.yml b/apps/commerce-api/src/main/resources/jpa.yml deleted file mode 100644 index d9eb878cb..000000000 --- a/apps/commerce-api/src/main/resources/jpa.yml +++ /dev/null @@ -1,19 +0,0 @@ -datasource: - mysql-jpa: - main: - jdbc-url: "jdbc:mysql://127.0.0.1:3306/loopers?serverTimezone=Asia/Seoul&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true" - username: application - password: application - driver-class-name: com.mysql.cj.jdbc.Driver - maximum-pool-size: 10 - minimum-idle: 5 - pool-name: MyHikariCP - -spring: - jpa: - hibernate: - ddl-auto: update - show-sql: true - properties: - hibernate: - format_sql: true \ No newline at end of file diff --git a/apps/commerce-api/src/main/resources/redis.yml b/apps/commerce-api/src/main/resources/redis.yml deleted file mode 100644 index f1f71fd1f..000000000 --- a/apps/commerce-api/src/main/resources/redis.yml +++ /dev/null @@ -1,7 +0,0 @@ -datasource: - redis: - database: 0 - master: - host: 127.0.0.1 - port: 6379 - replicas: [] \ No newline at end of file diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java index ba862cec6..df5122d5a 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.consumer; -import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.config.kafka.KafkaConfig; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; diff --git a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java b/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java similarity index 99% rename from modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java rename to modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java index a73842775..ce5b10871 100644 --- a/modules/kafka/src/main/java/com/loopers/confg/kafka/KafkaConfig.java +++ b/modules/kafka/src/main/java/com/loopers/config/kafka/KafkaConfig.java @@ -1,4 +1,4 @@ -package com.loopers.confg.kafka; +package com.loopers.config.kafka; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerConfig; From 09fa5e7eae422a19f43cad734a0f8ed79097676c Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 18 Dec 2025 19:44:28 +0900 Subject: [PATCH 140/164] =?UTF-8?q?feature:=20Outbox=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `OutboxEvent` ์—”ํ‹ฐํ‹ฐ ๋ฐ JPA ๋งคํ•‘ ์ถ”๊ฐ€๋กœ Outbox ๊ธฐ๋ฐ˜ ์ด๋ฒคํŠธ ๊ด€๋ฆฌ ๊ตฌํ˜„ - `OutboxService`๋ฅผ ํ†ตํ•ด ์ด๋ฒคํŠธ ์ €์žฅ ๋ฐ ์ง๋ ฌํ™” ๋กœ์ง ์ฒ˜๋ฆฌ - `OutboxRepository`๋กœ ๋น„๋™๊ธฐ ์ด๋ฒคํŠธ ์กฐํšŒ ๋ฐ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ --- .../com/loopers/domain/event/OutboxEvent.java | 41 +++++++++++++++++++ .../loopers/domain/event/OutboxService.java | 33 +++++++++++++++ .../event/OutboxRepository.java | 15 +++++++ 3 files changed, 89 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java new file mode 100644 index 000000000..34bbf8d8d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java @@ -0,0 +1,41 @@ +package com.loopers.domain.event; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String aggregateType; + private String aggregateId; + private String eventType; // ์˜ˆ: LikeCreatedEvent + + @Column(columnDefinition = "TEXT") + private String payload; + + private boolean published = false; + private LocalDateTime createdAt = LocalDateTime.now(); + + public OutboxEvent(String aggregateType, String aggregateId, String eventType, String payload) { + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.eventType = eventType; + this.payload = payload; + } + + public void markPublished() { + this.published = true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java new file mode 100644 index 000000000..85053de91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.event; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.infrastructure.event.OutboxRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class OutboxService { + + private final OutboxRepository outboxRepository; + private final ObjectMapper objectMapper; + + @Transactional(propagation = Propagation.MANDATORY) + public void saveEvent(String aggregateType, String aggregateId, Object event) { + try { + String payload = objectMapper.writeValueAsString(event); + OutboxEvent outboxEvent = new OutboxEvent( + aggregateType, + aggregateId, + event.getClass().getSimpleName(), + payload + ); + outboxRepository.save(outboxEvent); + } catch (JsonProcessingException e) { + throw new RuntimeException("์ด๋ฒคํŠธ ์ง๋ ฌํ™” ์‹คํŒจ", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java new file mode 100644 index 000000000..39159033b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.event; + +import com.loopers.domain.event.OutboxEvent; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OutboxRepository extends JpaRepository { + + List findAllByPublishedFalse(); + + Optional findFirstByAggregateIdAndEventTypeAndPublishedFalseOrderByCreatedAtDesc(String aggregateId, String eventType); +} From c91cd669089fa234b43361e9f1090d6c3faf83c8 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 08:11:37 +0900 Subject: [PATCH 141/164] =?UTF-8?q?feature:=20Outbox=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20Kafka=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `OutboxEvent` ๊ตฌ์กฐ ํ™•์žฅ: ๊ณ ์œ  `eventId` ํ•„๋“œ ์ถ”๊ฐ€ ๋ฐ ์ €์žฅ ๋กœ์ง ๊ฐœ์„ . - `LikeCreatedEvent`์˜ ์ƒ์„ฑ ๋ฉ”์„œ๋“œ(`of`) ๋„์ž…์œผ๋กœ eventId ์ž๋™ ์ƒ์„ฑ ๋ฐ ํƒ€์ž„์Šคํƒฌํ”„ ์ถ”๊ฐ€. - Kafka ์—ฐ๋™์„ ์œ„ํ•œ `LikeEventOutboxHandler` ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€. - `OutboxService`์— ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์™„๋ฃŒ ์ƒํƒœ ๊ด€๋ฆฌ ๋กœ์ง(`markPublished`) ์ถ”๊ฐ€. - `LikeFacade` ๋ฐ `LikeCountAggregateListener`์—์„œ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋กœ์ง ํ™•์žฅ ๋ฐ ๊ด€๋ จ ํด๋ž˜์Šค ํŒจํ‚ค์ง€ ๊ตฌ์กฐ ์ •๋ฆฌ. --- .../application/like/LikeCreatedEvent.java | 10 ------ .../like/LikeEventOutboxHandler.java | 32 +++++++++++++++++++ .../loopers/application/like/LikeFacade.java | 6 ++-- .../{ => event}/LikeActionTrackEvent.java | 2 +- .../like/event/LikeCreatedEvent.java | 21 ++++++++++++ .../product/LikeCountAggregateListener.java | 19 ++++++++--- .../com/loopers/domain/event/OutboxEvent.java | 5 +-- .../loopers/domain/event/OutboxService.java | 14 ++++++-- .../event/OutboxRepository.java | 3 +- 9 files changed, 88 insertions(+), 24 deletions(-) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java rename apps/commerce-api/src/main/java/com/loopers/application/like/{ => event}/LikeActionTrackEvent.java (93%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java deleted file mode 100644 index 88c242010..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeCreatedEvent.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.event.DomainEvent; - -public record LikeCreatedEvent( - long productId, - int increment -) implements DomainEvent { - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java new file mode 100644 index 000000000..a67a2e9be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java @@ -0,0 +1,32 @@ +package com.loopers.application.like; + +import com.loopers.application.like.event.LikeCreatedEvent; +import com.loopers.domain.event.OutboxService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class LikeEventOutboxHandler { + private final KafkaTemplate kafkaTemplate; + private final OutboxService outboxService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(LikeCreatedEvent event) { + outboxService.saveEvent("PRODUCT_METRICS", String.valueOf(event.productId()), event); + + kafkaTemplate.send("catalog-events", String.valueOf(event.productId()), event) + .whenComplete((result, ex) -> { + if (ex == null) { + outboxService.markPublished(event.eventId()); + } else { + log.error("์นดํ”„์นด ์ „์†ก ์‹คํŒจ: {}", ex.getMessage()); + } + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 37930112c..5c58a88b5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,5 +1,7 @@ package com.loopers.application.like; +import com.loopers.application.like.event.LikeActionTrackEvent; +import com.loopers.application.like.event.LikeCreatedEvent; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; @@ -29,7 +31,7 @@ public LikeInfo like(long userId, long productId) { Like newLike = likeService.save(userId, productId); - eventPublisher.publishEvent(new LikeCreatedEvent(productId, 1)); + eventPublisher.publishEvent(LikeCreatedEvent.of(productId, 1)); eventPublisher.publishEvent(new LikeActionTrackEvent( userId, @@ -45,7 +47,7 @@ public int unLike(long userId, long productId) { likeService.unLike(userId, productId); - eventPublisher.publishEvent(new LikeCreatedEvent(productId, -1)); + eventPublisher.publishEvent(LikeCreatedEvent.of(productId, -1)); eventPublisher.publishEvent(new LikeActionTrackEvent( userId, diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java rename to apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java index 80795684e..881b9d413 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeActionTrackEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeActionTrackEvent.java @@ -1,4 +1,4 @@ -package com.loopers.application.like; +package com.loopers.application.like.event; import com.loopers.domain.event.UserActionTrackEvent; import java.time.ZonedDateTime; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java new file mode 100644 index 000000000..1d8d6660d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeCreatedEvent.java @@ -0,0 +1,21 @@ +package com.loopers.application.like.event; + +import com.loopers.domain.event.DomainEvent; +import java.util.UUID; + +public record LikeCreatedEvent( + String eventId, + long productId, + int increment, + long timestamp +) implements DomainEvent { + + public static LikeCreatedEvent of(Long productId, int increment) { + return new LikeCreatedEvent( + UUID.randomUUID().toString(), + productId, + increment, + System.currentTimeMillis() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java index 34a5812bc..6fda01171 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java @@ -1,9 +1,11 @@ package com.loopers.application.product; import com.loopers.application.event.FailedEventStore; -import com.loopers.application.like.LikeCreatedEvent; +import com.loopers.application.like.event.LikeCreatedEvent; +import com.loopers.event.LikeKafkaEvent; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -18,6 +20,7 @@ public class LikeCountAggregateListener { private final ProductService productService; private final FailedEventStore failedEventStore; + private final ApplicationEventPublisher eventPublisher; @Async @@ -25,7 +28,13 @@ public class LikeCountAggregateListener { public void handleLikeCreatedEvent(LikeCreatedEvent event) { try { - performAggregation(event); + int updatedLikeCount = performAggregation(event); + + eventPublisher.publishEvent(new LikeKafkaEvent( + event.eventId(), + event.productId(), + updatedLikeCount + )); } catch (ObjectOptimisticLockingFailureException e) { @@ -38,11 +47,11 @@ public void handleLikeCreatedEvent(LikeCreatedEvent event) { } @Transactional(propagation = Propagation.REQUIRES_NEW) - public void performAggregation(LikeCreatedEvent event) { + public int performAggregation(LikeCreatedEvent event) { if (event.increment() > 0) { - productService.increaseLikeCount(event.productId()); + return productService.increaseLikeCount(event.productId()); } else { - productService.decreaseLikeCount(event.productId()); + return productService.decreaseLikeCount(event.productId()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java index 34bbf8d8d..5150f8a1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java @@ -17,7 +17,7 @@ public class OutboxEvent { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - + private String eventId; private String aggregateType; private String aggregateId; private String eventType; // ์˜ˆ: LikeCreatedEvent @@ -28,7 +28,8 @@ public class OutboxEvent { private boolean published = false; private LocalDateTime createdAt = LocalDateTime.now(); - public OutboxEvent(String aggregateType, String aggregateId, String eventType, String payload) { + public OutboxEvent(String eventId, String aggregateType, String aggregateId, String eventType, String payload) { + this.eventId = eventId; this.aggregateType = aggregateType; this.aggregateId = aggregateId; this.eventType = eventType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java index 85053de91..7981b5c18 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java @@ -18,16 +18,26 @@ public class OutboxService { @Transactional(propagation = Propagation.MANDATORY) public void saveEvent(String aggregateType, String aggregateId, Object event) { try { + String eventId = (String) event.getClass().getMethod("eventId").invoke(event); String payload = objectMapper.writeValueAsString(event); OutboxEvent outboxEvent = new OutboxEvent( + eventId, aggregateType, aggregateId, event.getClass().getSimpleName(), payload ); outboxRepository.save(outboxEvent); - } catch (JsonProcessingException e) { - throw new RuntimeException("์ด๋ฒคํŠธ ์ง๋ ฌํ™” ์‹คํŒจ", e); + } catch (Exception e) { + throw new RuntimeException("Outbox ์ €์žฅ ์‹คํŒจ", e); } } + + @Transactional + public void markPublished(String eventId) { + outboxRepository.findByEventId(eventId).ifPresent(event -> { + event.markPublished(); + outboxRepository.save(event); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java index 39159033b..44ba09a99 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java @@ -9,7 +9,6 @@ @Repository public interface OutboxRepository extends JpaRepository { - List findAllByPublishedFalse(); + Optional findByEventId(String eventId); - Optional findFirstByAggregateIdAndEventTypeAndPublishedFalseOrderByCreatedAtDesc(String aggregateId, String eventType); } From 75b2811bcb0b0aa2c0a45e2f6cb48f391c91c038 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 08:13:41 +0900 Subject: [PATCH 142/164] =?UTF-8?q?feature:=20Kafka=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `LikeEventConsumer` ์ถ”๊ฐ€๋กœ Kafka ์ด๋ฒคํŠธ ์ˆ˜์‹  ๋ฐ ์ฒ˜๋ฆฌ ๋กœ์ง ๊ตฌํ˜„. - `ProductMetricsService`๋ฅผ ํ†ตํ•ด ์ข‹์•„์š” ์ˆ˜ ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ. - `ProductMetrics` ๋ฐ `EventHandled` ์—”ํ‹ฐํ‹ฐ ์ถ”๊ฐ€๋กœ ์ด๋ฒคํŠธ ์ค‘๋ณต ์ฒ˜๋ฆฌ ๋ฐฉ์ง€ ๋ฐ ๋ฉ”ํŠธ๋ฆญ ์ €์žฅ. - ๊ด€๋ จ JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค (`ProductMetricsRepository`, `EventHandledRepository`) ์ถ”๊ฐ€. --- .../loopers/domain/event/EventHandled.java | 23 ++++++++++++ .../domain/metrics/ProductMetrics.java | 33 +++++++++++++++++ .../domain/metrics/ProductMetricsService.java | 33 +++++++++++++++++ .../EventHandledRepository.java | 10 ++++++ .../ProductMetricsRepository.java | 10 ++++++ .../consumer/LikeEventConsumer.java | 35 +++++++++++++++++++ .../com/loopers/event/LikeKafkaEvent.java | 9 +++++ 7 files changed, 153 insertions(+) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java create mode 100644 modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java new file mode 100644 index 000000000..68430fef3 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java @@ -0,0 +1,23 @@ +package com.loopers.domain.event; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EventHandled { + + @Id + private String eventId; + private LocalDateTime processedAt; + + public EventHandled(String eventId) { + this.eventId = eventId; + this.processedAt = LocalDateTime.now(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..2070e30bf --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,33 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductMetrics { + + @Id + private Long productId; + + private int likeCount; + + private LocalDateTime updatedAt; + + public ProductMetrics(Long productId) { + this.productId = productId; + } + + public void updateLikeCount(int newCount, LocalDateTime eventTime) { + if (this.updatedAt != null && eventTime.isBefore(this.updatedAt)) { + return; + } + this.likeCount = newCount; + this.updatedAt = eventTime; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java new file mode 100644 index 000000000..3b0d5409e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.metrics; + +import com.loopers.domain.event.EventHandled; +import com.loopers.event.LikeKafkaEvent; +import com.loopers.infrastructure.EventHandledRepository; +import com.loopers.infrastructure.ProductMetricsRepository; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class ProductMetricsService { + + private final ProductMetricsRepository metricsRepository; + private final EventHandledRepository eventHandledRepository; + + @Transactional + public void processLikeEvent(LikeKafkaEvent event) { + if (eventHandledRepository.existsById(event.eventId())) { + return; + } + + ProductMetrics metrics = metricsRepository.findById(event.productId()) + .orElseGet(() -> new ProductMetrics(event.productId())); + + metrics.updateLikeCount(event.currentLikeCount(), LocalDateTime.now()); + metricsRepository.save(metrics); + + eventHandledRepository.save(new EventHandled(event.eventId())); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java new file mode 100644 index 000000000..a77e94a3c --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/EventHandledRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.event.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EventHandledRepository extends JpaRepository { + +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java new file mode 100644 index 000000000..b8732b5fd --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductMetricsRepository extends JpaRepository { + +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java new file mode 100644 index 000000000..4fa94a933 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.event.LikeKafkaEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class LikeEventConsumer { + + private final ProductMetricsService metricsService; + + @KafkaListener( + topics = "catalog-events", + groupId = "metrics-group" + ) + public void onMessage(ConsumerRecord record, Acknowledgment ack) { + try { + log.info("์ด๋ฒคํŠธ ์ˆ˜์‹ : {}", record.value().eventId()); + + metricsService.processLikeEvent(record.value()); + + ack.acknowledge(); + + } catch (Exception e) { + log.error("์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹คํŒจ, ๋‹ค์Œ ์žฌ์‹œ๋„๋ฅผ ์œ„ํ•ด Ack๋ฅผ ํ•˜์ง€ ์•Š์Œ: {}", e.getMessage()); + } + } +} diff --git a/modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java b/modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java new file mode 100644 index 000000000..1af45c4b7 --- /dev/null +++ b/modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java @@ -0,0 +1,9 @@ +package com.loopers.event; + +public record LikeKafkaEvent( + String eventId, + Long productId, + int currentLikeCount +) { + +} From 6eaf1aa576e60d6ab4d64ac99d203ea1fa997d17 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 08:44:19 +0900 Subject: [PATCH 143/164] =?UTF-8?q?-=20LikeKafkaEvent=EB=A5=BC=20LikeCount?= =?UTF-8?q?Event=EB=A1=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-=20=EC=9D=B4=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20=EB=B0=8F=20=ED=8D=BC?= =?UTF-8?q?=EB=B8=94=EB=A6=AC=EC=85=94=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20OrderCreatedEvent,=20LikeEventOutboxHa?= =?UTF-8?q?ndler,=20LikeCountAggregateListener=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/{ => event}/LikeEventOutboxHandler.java | 3 +-- .../application/order/{ => event}/OrderCreatedEvent.java | 2 +- .../product/{ => event}/LikeCountAggregateListener.java | 6 +++--- .../com/loopers/interfaces/consumer/LikeEventConsumer.java | 4 ++-- .../event/{LikeKafkaEvent.java => LikeCountEvent.java} | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/application/like/{ => event}/LikeEventOutboxHandler.java (91%) rename apps/commerce-api/src/main/java/com/loopers/application/order/{ => event}/OrderCreatedEvent.java (85%) rename apps/commerce-api/src/main/java/com/loopers/application/product/{ => event}/LikeCountAggregateListener.java (92%) rename modules/kafka/src/main/java/com/loopers/event/{LikeKafkaEvent.java => LikeCountEvent.java} (76%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java rename to apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java index a67a2e9be..7e9d0f249 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventOutboxHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java @@ -1,6 +1,5 @@ -package com.loopers.application.like; +package com.loopers.application.like.event; -import com.loopers.application.like.event.LikeCreatedEvent; import com.loopers.domain.event.OutboxService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java index 0f3168ada..d833124cc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java @@ -1,4 +1,4 @@ -package com.loopers.application.order; +package com.loopers.application.order.event; import com.loopers.domain.payment.PaymentType; import com.loopers.domain.user.User; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java index 6fda01171..e0525938a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/LikeCountAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java @@ -1,8 +1,8 @@ -package com.loopers.application.product; +package com.loopers.application.product.event; import com.loopers.application.event.FailedEventStore; import com.loopers.application.like.event.LikeCreatedEvent; -import com.loopers.event.LikeKafkaEvent; +import com.loopers.event.LikeCountEvent; import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -30,7 +30,7 @@ public void handleLikeCreatedEvent(LikeCreatedEvent event) { try { int updatedLikeCount = performAggregation(event); - eventPublisher.publishEvent(new LikeKafkaEvent( + eventPublisher.publishEvent(new LikeCountEvent( event.eventId(), event.productId(), updatedLikeCount diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java index 4fa94a933..426176778 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.consumer; import com.loopers.domain.metrics.ProductMetricsService; -import com.loopers.event.LikeKafkaEvent; +import com.loopers.event.LikeCountEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -20,7 +20,7 @@ public class LikeEventConsumer { topics = "catalog-events", groupId = "metrics-group" ) - public void onMessage(ConsumerRecord record, Acknowledgment ack) { + public void onMessage(ConsumerRecord record, Acknowledgment ack) { try { log.info("์ด๋ฒคํŠธ ์ˆ˜์‹ : {}", record.value().eventId()); diff --git a/modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java b/modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java similarity index 76% rename from modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java rename to modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java index 1af45c4b7..541d50aab 100644 --- a/modules/kafka/src/main/java/com/loopers/event/LikeKafkaEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java @@ -1,6 +1,6 @@ package com.loopers.event; -public record LikeKafkaEvent( +public record LikeCountEvent( String eventId, Long productId, int currentLikeCount From 7af3ae8de478bf7133e46aa4879e09855c04dc68 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 08:46:23 +0900 Subject: [PATCH 144/164] =?UTF-8?q?feature:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8(ProductViewEve?= =?UTF-8?q?nt)=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Kafka=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EC=A0=81=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ์ƒํ’ˆ ์กฐํšŒ ์•ก์…˜์„ ๊ธฐ๋กํ•˜๊ธฐ ์œ„ํ•œ ProductViewEvent ๋„์ž… - ProductViewEvent๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ณ  Kafka์™€ ์—ฐ๋™ํ•˜๋Š” ProductEventOutboxHandler ๊ตฌํ˜„ - ์ƒํ’ˆ ์ •๋ณด ์กฐํšŒ ์‹œ ProductFacade์—์„œ ProductViewEvent ๋ฐœํ–‰ํ•˜๋„๋ก ์ˆ˜์ • - ์ด๋ฒคํŠธ ์ €์žฅ, ์ค‘๋ณต ์ œ๊ฑฐ, Kafka ๋ฐœํ–‰ ๋กœ์ง ํฌํ•จ --- .../application/product/ProductFacade.java | 5 +++ .../event/ProductEventOutboxHandler.java | 31 +++++++++++++++++++ .../com/loopers/event/ProductViewEvent.java | 18 +++++++++++ 3 files changed, 54 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java create mode 100644 modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 1982900da..fa44b983b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -3,7 +3,9 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; +import com.loopers.event.ProductViewEvent; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -14,6 +16,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final ApplicationEventPublisher eventPublisher; public Page getProductsInfo(Pageable pageable) { Page products = productService.getProducts(pageable); @@ -29,6 +32,8 @@ public ProductInfo getProductInfo(long id) { String brandName = brandService.getBrand(product.getBrandId()) .getName(); + eventPublisher.publishEvent(ProductViewEvent.from(id)); + return ProductInfo.from(product, brandName); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java new file mode 100644 index 000000000..47efc5bed --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java @@ -0,0 +1,31 @@ +package com.loopers.application.product.event; + +import com.loopers.domain.event.OutboxService; +import com.loopers.event.ProductViewEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class ProductEventOutboxHandler { + + private final OutboxService outboxService; + private final KafkaTemplate kafkaTemplate; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ProductViewEvent event) { + outboxService.saveEvent("PRODUCT_VIEW", String.valueOf(event.productId()), event); + + kafkaTemplate.send("catalog-events", String.valueOf(event.productId()), event) + .whenComplete((result, ex) -> { + if (ex == null) { + outboxService.markPublished(event.eventId()); + } + }); + } +} diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java new file mode 100644 index 000000000..55a0f03d4 --- /dev/null +++ b/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java @@ -0,0 +1,18 @@ +package com.loopers.event; + +import java.util.UUID; + +public record ProductViewEvent( + String eventId, + Long productId, + long timestamp +) { + + public static ProductViewEvent from(Long productId) { + return new ProductViewEvent( + UUID.randomUUID().toString(), + productId, + System.currentTimeMillis() + ); + } +} From 2a38caf1f185ba36bafb8c8df707a54df780039c Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 09:06:27 +0900 Subject: [PATCH 145/164] =?UTF-8?q?feature:=20Outbox=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=A3=BC=EB=AC=B8=20=EB=B0=8F=20=ED=8C=90=EB=A7=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B2=98=EB=A6=AC,=20Kafka=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `OrderEventOutboxHandler` ์ถ”๊ฐ€๋กœ ์ฃผ๋ฌธ ์ƒ์„ฑ ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๋ฐ Kafka ๋ฐœํ–‰ ๋กœ์ง ๊ตฌํ˜„. - `OrderSalesAggregateListener`๋ฅผ ํ†ตํ•ด ํŒ๋งค ์ง‘๊ณ„ Kafka ์ด๋ฒคํŠธ ๋ฐœํ–‰ ๋ฐ ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ์ง€์›. - `OrderCreatedEvent` ํ™•์žฅ: ๊ณ ์œ  `eventId` ์ถ”๊ฐ€ ๋ฐ ์ฃผ๋ฌธ ํ’ˆ๋ชฉ ์ „๋‹ฌ ์ •๋ณด ๊ตฌ์กฐ ๋ณ€๊ฒฝ. - ์‹ ๊ทœ `SalesCountEvent` ์ถ”๊ฐ€๋กœ Kafka ๊ธฐ๋ฐ˜ ํŒ๋งค ๋ฉ”ํŠธ๋ฆญ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ. - `OrderFacade`, `PointPaymentEventListener`, `PgPaymentEventListener` ๋“ฑ ๊ด€๋ จ ํด๋ž˜์Šค ์ˆ˜์ • ๋ฐ ํŒจํ‚ค์ง€ ์ •๋ฆฌ. --- .../application/order/OrderFacade.java | 4 +- .../order/event/OrderCreatedEvent.java | 42 ++++++++++++++++++- .../order/event/OrderEventOutboxHandler.java | 25 +++++++++++ .../event/OrderSalesAggregateListener.java | 34 +++++++++++++++ .../payment/PgPaymentEventListener.java | 4 +- .../point/PointPaymentEventListener.java | 11 ++++- .../com/loopers/event/SalesCountEvent.java | 20 +++++++++ 7 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java create mode 100644 modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 3871cac77..9d04e135d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,5 +1,6 @@ package com.loopers.application.order; +import com.loopers.application.order.event.OrderCreatedEvent; import com.loopers.domain.coupon.CouponService; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderCommand.Item; @@ -62,9 +63,10 @@ public OrderInfo placeOrder(PlaceOrder command) { productService.deductStock(products, orderItems); - OrderCreatedEvent orderEvent = new OrderCreatedEvent( + OrderCreatedEvent orderEvent = OrderCreatedEvent.of( order.getId(), user, + orderItems, finalPaymentAmount, command.paymentType(), command.cardType(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java index d833124cc..1ed0595d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java @@ -1,14 +1,52 @@ package com.loopers.application.order.event; +import com.loopers.domain.order.OrderItem; import com.loopers.domain.payment.PaymentType; import com.loopers.domain.user.User; +import java.util.List; +import java.util.UUID; public record OrderCreatedEvent( + String eventId, Long orderId, - User user, + Long userId, + List items, long finalAmount, PaymentType paymentType, String cardType, String cardNo, Long couponId -) {} +) { + + public record OrderItemInfo(Long productId, int quantity) { + + } + + public static OrderCreatedEvent of( + Long orderId, + User user, + List orderItems, + long finalAmount, + PaymentType paymentType, + String cardType, + String cardNo, + Long couponId + ) { + + List itemInfos = orderItems.stream() + .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) + .toList(); + + return new OrderCreatedEvent( + UUID.randomUUID().toString(), + orderId, + user.getId(), + itemInfos, + finalAmount, + paymentType, + cardType, + cardNo, + couponId + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java new file mode 100644 index 000000000..7ab03af7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java @@ -0,0 +1,25 @@ +package com.loopers.application.order.event; + +import com.loopers.domain.event.OutboxService; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class OrderEventOutboxHandler { + private final KafkaTemplate kafkaTemplate; + private final OutboxService outboxService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(OrderCreatedEvent event) { + kafkaTemplate.send("order-events", String.valueOf(event.orderId()), event) + .whenComplete((result, ex) -> { + if (ex == null) { + outboxService.markPublished(event.eventId()); + } + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java new file mode 100644 index 000000000..451599284 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java @@ -0,0 +1,34 @@ +package com.loopers.application.order.event; + +import com.loopers.domain.event.OutboxService; +import com.loopers.event.SalesCountEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class OrderSalesAggregateListener { + + private final OutboxService outboxService; + private final ApplicationEventPublisher eventPublisher; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderCreated(OrderCreatedEvent event) { + + event.items().forEach(item -> { + + SalesCountEvent kafkaEvent = SalesCountEvent.of( + item.productId(), + item.quantity() + ); + + outboxService.saveEvent("SALES_METRICS", String.valueOf(item.productId()), kafkaEvent); + eventPublisher.publishEvent(kafkaEvent); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java index b189aab6b..c905688f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PgPaymentEventListener.java @@ -1,6 +1,6 @@ package com.loopers.application.payment; -import com.loopers.application.order.OrderCreatedEvent; +import com.loopers.application.order.event.OrderCreatedEvent; import com.loopers.application.payment.PaymentEvent.PaymentRequestFailedEvent; import com.loopers.application.payment.PaymentEvent.PaymentRequestedEvent; import com.loopers.domain.payment.Payment; @@ -36,7 +36,7 @@ public void handleOrderCreatedEvent(OrderCreatedEvent event) { return; } - User user = userService.findById(event.user().getId()) + User user = userService.findById(event.userId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์œ ์ € ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); PaymentProcessor processor = paymentProcessors.stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java index 8c9a04751..675883bc1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointPaymentEventListener.java @@ -1,6 +1,6 @@ package com.loopers.application.point; -import com.loopers.application.order.OrderCreatedEvent; +import com.loopers.application.order.event.OrderCreatedEvent; import com.loopers.application.payment.PaymentEvent.PaymentCompletedEvent; import com.loopers.application.payment.PaymentEvent.PaymentRequestFailedEvent; import com.loopers.domain.order.OrderService; @@ -8,6 +8,8 @@ import com.loopers.domain.payment.Payment; import com.loopers.domain.payment.PaymentProcessor; import com.loopers.domain.payment.PaymentType; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.List; @@ -23,8 +25,10 @@ @Component @RequiredArgsConstructor public class PointPaymentEventListener { + private final List paymentProcessors; private final OrderService orderService; + private final UserService userService; private final ApplicationEventPublisher eventPublisher; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -40,10 +44,13 @@ public void handleOrderCreatedEvent(OrderCreatedEvent event) { .findFirst() .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "ํฌ์ธํŠธ ๊ฒฐ์ œ ํ”„๋กœ์„ธ์„œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + User user = userService.findById(event.userId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + try { Payment payment = processor.process( event.orderId(), - event.user(), + user, event.finalAmount(), Map.of() ); diff --git a/modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java b/modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java new file mode 100644 index 000000000..972e89a9c --- /dev/null +++ b/modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java @@ -0,0 +1,20 @@ +package com.loopers.event; + +import java.util.UUID; + +public record SalesCountEvent( + String eventId, + Long productId, + int quantity, + long timestamp +) { + + public static SalesCountEvent of(Long productId, int quantity) { + return new SalesCountEvent( + UUID.randomUUID().toString(), + productId, + quantity, + System.currentTimeMillis() + ); + } +} From 67344b8dc3cbfda19f21aef5b2ff8ac625cae045 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 09:24:39 +0900 Subject: [PATCH 146/164] =?UTF-8?q?feature:=20=EB=A9=94=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=BB=A8=EC=8A=88=EB=A8=B8=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=EB=B0=8F=20Kafka=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=A7=80=ED=91=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `LikeEventConsumer`๋ฅผ `MetricsEventConsumer`๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  ์ƒํ’ˆ ์ง€ํ‘œ(์ข‹์•„์š”, ์กฐํšŒ์ˆ˜, ํŒ๋งค๋Ÿ‰) ์ฒ˜๋ฆฌ ์ง€์›. - `ProductMetricsService` ํ™•์žฅ: ์กฐํšŒ์ˆ˜(`ProductViewEvent`), ํŒ๋งค๋Ÿ‰(`SalesCountEvent`) ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ๋กœ์ง ์ถ”๊ฐ€. - `ProductMetrics` ์—”ํ‹ฐํ‹ฐ์— ์กฐํšŒ์ˆ˜์™€ ํŒ๋งค๋Ÿ‰ ์†์„ฑ ๋ฐ ๊ด€๋ จ ์—…๋ฐ์ดํŠธ ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€. --- .../domain/metrics/ProductMetrics.java | 14 ++++- .../domain/metrics/ProductMetricsService.java | 52 +++++++++++++--- .../consumer/LikeEventConsumer.java | 35 ----------- .../consumer/MetricsEventConsumer.java | 59 +++++++++++++++++++ 4 files changed, 115 insertions(+), 45 deletions(-) delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index 2070e30bf..757b853d1 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -15,7 +15,9 @@ public class ProductMetrics { @Id private Long productId; - private int likeCount; + private int likeCount = 0; + private int viewCount = 0; + private int salesCount = 0; private LocalDateTime updatedAt; @@ -30,4 +32,14 @@ public void updateLikeCount(int newCount, LocalDateTime eventTime) { this.likeCount = newCount; this.updatedAt = eventTime; } + + public void incrementViewCount() { + this.viewCount += 1; + this.updatedAt = LocalDateTime.now(); + } + + public void addSalesCount(int quantity) { + this.salesCount += quantity; + this.updatedAt = LocalDateTime.now(); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index 3b0d5409e..95d47dd2d 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -1,7 +1,9 @@ package com.loopers.domain.metrics; import com.loopers.domain.event.EventHandled; -import com.loopers.event.LikeKafkaEvent; +import com.loopers.event.LikeCountEvent; +import com.loopers.event.ProductViewEvent; +import com.loopers.event.SalesCountEvent; import com.loopers.infrastructure.EventHandledRepository; import com.loopers.infrastructure.ProductMetricsRepository; import java.time.LocalDateTime; @@ -17,17 +19,49 @@ public class ProductMetricsService { private final EventHandledRepository eventHandledRepository; @Transactional - public void processLikeEvent(LikeKafkaEvent event) { - if (eventHandledRepository.existsById(event.eventId())) { - return; - } + public void processLikeCountEvent(LikeCountEvent event) { + if (isAlreadyHandled(event.eventId())) return; - ProductMetrics metrics = metricsRepository.findById(event.productId()) - .orElseGet(() -> new ProductMetrics(event.productId())); + ProductMetrics metrics = getOrCreateMetrics(event.productId()); metrics.updateLikeCount(event.currentLikeCount(), LocalDateTime.now()); - metricsRepository.save(metrics); - eventHandledRepository.save(new EventHandled(event.eventId())); + completeProcess(event.eventId(), metrics); + } + + @Transactional + public void processProductViewEvent(ProductViewEvent event) { + if (isAlreadyHandled(event.eventId())) return; + + ProductMetrics metrics = getOrCreateMetrics(event.productId()); + + metrics.incrementViewCount(); + + completeProcess(event.eventId(), metrics); + } + + @Transactional + public void processSalesCountEvent(SalesCountEvent event) { + if (isAlreadyHandled(event.eventId())) return; + + ProductMetrics metrics = getOrCreateMetrics(event.productId()); + + metrics.addSalesCount(event.quantity()); + + completeProcess(event.eventId(), metrics); + } + + private boolean isAlreadyHandled(String eventId) { + return eventHandledRepository.existsById(eventId); + } + + private ProductMetrics getOrCreateMetrics(Long productId) { + return metricsRepository.findById(productId) + .orElseGet(() -> new ProductMetrics(productId)); + } + + private void completeProcess(String eventId, ProductMetrics metrics) { + metricsRepository.save(metrics); + eventHandledRepository.save(new EventHandled(eventId)); } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java deleted file mode 100644 index 426176778..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/LikeEventConsumer.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.loopers.interfaces.consumer; - -import com.loopers.domain.metrics.ProductMetricsService; -import com.loopers.event.LikeCountEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Slf4j -public class LikeEventConsumer { - - private final ProductMetricsService metricsService; - - @KafkaListener( - topics = "catalog-events", - groupId = "metrics-group" - ) - public void onMessage(ConsumerRecord record, Acknowledgment ack) { - try { - log.info("์ด๋ฒคํŠธ ์ˆ˜์‹ : {}", record.value().eventId()); - - metricsService.processLikeEvent(record.value()); - - ack.acknowledge(); - - } catch (Exception e) { - log.error("์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹คํŒจ, ๋‹ค์Œ ์žฌ์‹œ๋„๋ฅผ ์œ„ํ•ด Ack๋ฅผ ํ•˜์ง€ ์•Š์Œ: {}", e.getMessage()); - } - } -} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java new file mode 100644 index 000000000..a7abbcdc6 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.consumer; + +import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.event.LikeCountEvent; +import com.loopers.event.ProductViewEvent; +import com.loopers.event.SalesCountEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MetricsEventConsumer { + + private final ProductMetricsService metricsService; + + @KafkaListener( + topics = "catalog-events", + groupId = "metrics-group" + ) + public void consumeLikeCount(ConsumerRecord record, Acknowledgment ack) { + try { + metricsService.processLikeCountEvent(record.value()); + ack.acknowledge(); + } catch (Exception e) { + log.error("์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", e.getMessage()); + } + } + + @KafkaListener( + topics = "catalog-events", + groupId = "metrics-group" + ) + public void consumeProductView(ProductViewEvent event, Acknowledgment ack) { + try { + metricsService.processProductViewEvent(event); + ack.acknowledge(); + } catch (Exception e) { + log.error("์กฐํšŒ์ˆ˜ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", event.eventId(), e); + } + } + + @KafkaListener( + topics = "catalog-events", + groupId = "metrics-group" + ) + public void consumeSalesCount(SalesCountEvent event, Acknowledgment ack) { + try { + metricsService.processSalesCountEvent(event); + ack.acknowledge(); + } catch (Exception e) { + log.error("ํŒ๋งค๋Ÿ‰ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", event.eventId(), e); + } + } +} From 432cd7430061e3bd2c96d2fb5b531bafad70ad6c Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 13:23:41 +0900 Subject: [PATCH 147/164] =?UTF-8?q?feat:=20JPA=20@Table=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A9=B1=EB=93=B1?= =?UTF-8?q?=EC=84=B1=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 - `OutboxEvent`, `ProductMetrics`, `EventHandled` ์—”ํ‹ฐํ‹ฐ์— @Table ์• ๋„ˆํ…Œ์ด์…˜ ์ถ”๊ฐ€. - `IdempotencyIntegrationTest` ๊ตฌํ˜„์œผ๋กœ ์ค‘๋ณต ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ๊ฒ€์ฆ. --- .../com/loopers/domain/event/OutboxEvent.java | 2 + .../loopers/domain/event/EventHandled.java | 2 + .../domain/metrics/ProductMetrics.java | 2 + .../metrics/IdempotencyIntegrationTest.java | 55 +++++++++++++++++++ 4 files changed, 61 insertions(+) create mode 100644 apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java index 5150f8a1d..b42a9d094 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java @@ -5,12 +5,14 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Table; import java.time.LocalDateTime; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter +@Table(name = "outbox_event") @NoArgsConstructor public class OutboxEvent { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java index 68430fef3..6f5bb5b68 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/event/EventHandled.java @@ -2,6 +2,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Table; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; @@ -9,6 +10,7 @@ @Entity @Getter +@Table(name = "event_handled") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class EventHandled { diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java index 757b853d1..a519fea7f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -2,6 +2,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.Id; +import jakarta.persistence.Table; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Getter; @@ -9,6 +10,7 @@ @Entity @Getter +@Table(name = "product_metrics") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ProductMetrics { diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java new file mode 100644 index 000000000..0dc0391eb --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.loopers.event.SalesCountEvent; +import com.loopers.infrastructure.ProductMetricsRepository; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class IdempotencyIntegrationTest { + + @Autowired + private ProductMetricsService metricsService; + + @Autowired + private ProductMetricsRepository metricsRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + + @Test + @DisplayName("์ค‘๋ณต ์ด๋ฒคํŠธ ์ˆ˜์‹  ์‹œ์—๋„ ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์น˜๋Š” ๋‹จ ํ•œ ๋ฒˆ๋งŒ ๋ฐ˜์˜๋˜์–ด์•ผ ํ•œ๋‹ค") + void shouldHandleDuplicateEventIdempotently() { + // Given: ๋™์ผํ•œ ID๋ฅผ ๊ฐ€์ง„ ์ด๋ฒคํŠธ ์ค€๋น„ + Long productId = 99L; + int salesQuantity = 2; + + SalesCountEvent firstEvent = SalesCountEvent.of(productId, salesQuantity); + + // When: ์ฒซ ๋ฒˆ์งธ ์ „์†ก + metricsService.processSalesCountEvent(firstEvent); + + // Then: ์ˆ˜์น˜ ๋ฐ˜์˜ ํ™•์ธ + int firstResult = metricsRepository.findById(productId).get().getSalesCount(); + assertThat(firstResult).isEqualTo(2); + + // When: ๋™์ผ ID๋กœ ๋‘ ๋ฒˆ์งธ ์ „์†ก (Kafka ์žฌ์ „์†ก ์‹œ๋‚˜๋ฆฌ์˜ค) + metricsService.processSalesCountEvent(firstEvent); + + // Then: ์ˆ˜์น˜๊ฐ€ 4๊ฐ€ ์•„๋‹ˆ๋ผ ์—ฌ์ „ํžˆ 2์—ฌ์•ผ ํ•จ (๋ฉฑ๋“ฑ์„ฑ ์„ฑ๊ณต) + int secondResult = metricsRepository.findById(productId).get().getSalesCount(); + assertThat(secondResult).isEqualTo(2); + } +} From ceb53ce33bdee4c4cf6fffe07bd29a5959323232 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 14:20:42 +0900 Subject: [PATCH 148/164] =?UTF-8?q?refactor:=20SalesCountEvent=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=9E=91=EC=97=85=EC=9D=84=20=EC=9C=84=ED=95=9CProductStock?= =?UTF-8?q?Event=20=EA=B8=B0=EB=B0=98=20=EC=9E=AC=EA=B3=A0=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SalesCountEvent๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ๊ด€๋ จ ๋กœ์ง์„ ProductStockEvent๋กœ ์ „๋ฉด ๋Œ€์ฒด - MetricsEventConsumer์—์„œ ProductStockEvent๋ฅผ ์ฒ˜๋ฆฌํ•˜๋„๋ก ์ˆ˜์ • - ProductMetricsService๋ฅผ ํ™•์žฅํ•˜์—ฌ ์žฌ๊ณ  ๊ธฐ๋ฐ˜ ์ง€ํ‘œ ๊ด€๋ฆฌ ๋ฐ ์žฌ๊ณ  ์†Œ์ง„ ์‹œ Redis ์บ์‹œ ์‚ญ์ œ ๋กœ์ง ์ถ”๊ฐ€ - ProductRepository์— findStockById ๋ฉ”์„œ๋“œ, ProductService์— getStock ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ - Redis ์บ์‹œ์—์„œ ํŽ˜์ด์ง• ์‘๋‹ต์„ ๊ฐ์‹ธ๊ธฐ ์œ„ํ•œ PageWrapper ๋„์ž… - ํŒจํ„ด ๊ธฐ๋ฐ˜ ์บ์‹œ ์‚ญ์ œ๋ฅผ ์ง€์›ํ•˜๋„๋ก RedisCacheHandler ์ด๋™ ๋ฐ ๊ธฐ๋Šฅ ํ™•์žฅ --- .../event/OrderSalesAggregateListener.java | 12 ++++--- .../domain/product/ProductRepository.java | 2 ++ .../domain/product/ProductService.java | 6 +++- .../domain/metrics/ProductMetricsService.java | 14 +++++++-- .../consumer/MetricsEventConsumer.java | 4 +-- .../metrics/IdempotencyIntegrationTest.java | 13 ++++++-- .../com/loopers/event/ProductStockEvent.java | 21 +++++++++++++ .../com/loopers/event/SalesCountEvent.java | 20 ------------ .../core}/cache/RedisCacheHandler.java | 16 ++++++++-- .../loopers/core/cache/page/PageWrapper.java | 31 +++++++++++++++++++ 10 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java delete mode 100644 modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java rename {apps/commerce-api/src/main/java/com/loopers/support => modules/redis/src/main/java/com/loopers/core}/cache/RedisCacheHandler.java (79%) create mode 100644 modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java index 451599284..925696cd7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java @@ -1,7 +1,8 @@ package com.loopers.application.order.event; import com.loopers.domain.event.OutboxService; -import com.loopers.event.SalesCountEvent; +import com.loopers.domain.product.ProductService; +import com.loopers.event.ProductStockEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; @@ -13,6 +14,7 @@ @RequiredArgsConstructor public class OrderSalesAggregateListener { + private final ProductService productService; private final OutboxService outboxService; private final ApplicationEventPublisher eventPublisher; @@ -22,12 +24,14 @@ public void handleOrderCreated(OrderCreatedEvent event) { event.items().forEach(item -> { - SalesCountEvent kafkaEvent = SalesCountEvent.of( + int currentStock = productService.getStock(item.productId()); + ProductStockEvent kafkaEvent = ProductStockEvent.of( item.productId(), - item.quantity() + item.quantity(), + currentStock ); - outboxService.saveEvent("SALES_METRICS", String.valueOf(item.productId()), kafkaEvent); + outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent); eventPublisher.publishEvent(kafkaEvent); }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 7bd29a48b..d4e9c052e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -13,4 +13,6 @@ public interface ProductRepository { Optional findById(Long id); Page findByBrandId(Long brandId, Pageable pageable); + + int findStockById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 7a52560d6..6f369a766 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,7 +1,7 @@ package com.loopers.domain.product; import com.loopers.domain.order.OrderItem; -import com.loopers.support.cache.RedisCacheHandler; +import com.loopers.core.cache.RedisCacheHandler; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.time.Duration; @@ -104,4 +104,8 @@ private String makeCacheKey(String prefix, Pageable pageable) { } return sb.toString(); } + + public int getStock(Long id) { + return productRepository.findStockById(id); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index 95d47dd2d..b259518c2 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -1,9 +1,10 @@ package com.loopers.domain.metrics; +import com.loopers.core.cache.RedisCacheHandler; import com.loopers.domain.event.EventHandled; import com.loopers.event.LikeCountEvent; +import com.loopers.event.ProductStockEvent; import com.loopers.event.ProductViewEvent; -import com.loopers.event.SalesCountEvent; import com.loopers.infrastructure.EventHandledRepository; import com.loopers.infrastructure.ProductMetricsRepository; import java.time.LocalDateTime; @@ -17,6 +18,7 @@ public class ProductMetricsService { private final ProductMetricsRepository metricsRepository; private final EventHandledRepository eventHandledRepository; + private final RedisCacheHandler redisCacheHandler; @Transactional public void processLikeCountEvent(LikeCountEvent event) { @@ -41,12 +43,18 @@ public void processProductViewEvent(ProductViewEvent event) { } @Transactional - public void processSalesCountEvent(SalesCountEvent event) { + public void processSalesCountEvent(ProductStockEvent event) { if (isAlreadyHandled(event.eventId())) return; ProductMetrics metrics = getOrCreateMetrics(event.productId()); - metrics.addSalesCount(event.quantity()); + metrics.addSalesCount(event.sellQuantity()); + + if (event.currentStock() <= 0) { + redisCacheHandler.delete("product:detail:" + event.productId()); + redisCacheHandler.deleteByPattern("product:list"); + + } completeProcess(event.eventId(), metrics); } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java index a7abbcdc6..5745f467f 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -2,8 +2,8 @@ import com.loopers.domain.metrics.ProductMetricsService; import com.loopers.event.LikeCountEvent; +import com.loopers.event.ProductStockEvent; import com.loopers.event.ProductViewEvent; -import com.loopers.event.SalesCountEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; @@ -48,7 +48,7 @@ public void consumeProductView(ProductViewEvent event, Acknowledgment ack) { topics = "catalog-events", groupId = "metrics-group" ) - public void consumeSalesCount(SalesCountEvent event, Acknowledgment ack) { + public void consumeSalesCount(ProductStockEvent event, Acknowledgment ack) { try { metricsService.processSalesCountEvent(event); ack.acknowledge(); diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java index 0dc0391eb..669bb6751 100644 --- a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/IdempotencyIntegrationTest.java @@ -2,7 +2,9 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.loopers.event.SalesCountEvent; +import com.loopers.config.redis.RedisConfig; +import com.loopers.core.cache.RedisCacheHandler; +import com.loopers.event.ProductStockEvent; import com.loopers.infrastructure.ProductMetricsRepository; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -10,8 +12,11 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest +@Import(RedisConfig.class) class IdempotencyIntegrationTest { @Autowired @@ -20,6 +25,9 @@ class IdempotencyIntegrationTest { @Autowired private ProductMetricsRepository metricsRepository; + @MockitoBean + private RedisCacheHandler redisCacheHandler; + @Autowired private DatabaseCleanUp databaseCleanUp; @@ -35,8 +43,9 @@ void shouldHandleDuplicateEventIdempotently() { // Given: ๋™์ผํ•œ ID๋ฅผ ๊ฐ€์ง„ ์ด๋ฒคํŠธ ์ค€๋น„ Long productId = 99L; int salesQuantity = 2; + int currentStock = 10; - SalesCountEvent firstEvent = SalesCountEvent.of(productId, salesQuantity); + ProductStockEvent firstEvent = ProductStockEvent.of(productId, salesQuantity, currentStock); // When: ์ฒซ ๋ฒˆ์งธ ์ „์†ก metricsService.processSalesCountEvent(firstEvent); diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java new file mode 100644 index 000000000..2b7c466ef --- /dev/null +++ b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java @@ -0,0 +1,21 @@ +package com.loopers.event; + +import java.util.UUID; + +public record ProductStockEvent( + String eventId, + Long productId, + int sellQuantity, + int currentStock, + long timestamp +) { + public static ProductStockEvent of(Long productId, int sellQuantity, int currentStock) { + return new ProductStockEvent( + UUID.randomUUID().toString(), + productId, + sellQuantity, + currentStock, + System.currentTimeMillis() + ); + } +} diff --git a/modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java b/modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java deleted file mode 100644 index 972e89a9c..000000000 --- a/modules/kafka/src/main/java/com/loopers/event/SalesCountEvent.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.loopers.event; - -import java.util.UUID; - -public record SalesCountEvent( - String eventId, - Long productId, - int quantity, - long timestamp -) { - - public static SalesCountEvent of(Long productId, int quantity) { - return new SalesCountEvent( - UUID.randomUUID().toString(), - productId, - quantity, - System.currentTimeMillis() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java b/modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java rename to modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java index 2c6c41e7e..8d20d113b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/cache/RedisCacheHandler.java +++ b/modules/redis/src/main/java/com/loopers/core/cache/RedisCacheHandler.java @@ -1,7 +1,8 @@ -package com.loopers.support.cache; +package com.loopers.core.cache; -import com.loopers.support.page.PageWrapper; +import com.loopers.core.cache.page.PageWrapper; import java.time.Duration; +import java.util.Set; import java.util.function.Supplier; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -49,4 +50,15 @@ public T getOrLoad(String key, Duration ttl, Class type, Supplier dbFe return result; } + + public void delete(String key) { + redisTemplate.delete(key); + } + + public void deleteByPattern(String pattern) { + Set keys = redisTemplate.keys(pattern + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } } diff --git a/modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java b/modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java new file mode 100644 index 000000000..0cb5539c1 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/core/cache/page/PageWrapper.java @@ -0,0 +1,31 @@ +package com.loopers.core.cache.page; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +public class PageWrapper { + private List content; + private long totalElements; + private int pageNumber; + private int pageSize; + + public PageWrapper() {} + + public PageWrapper(Page page) { + this.content = page.getContent(); + this.totalElements = page.getTotalElements(); + this.pageNumber = page.getNumber(); + this.pageSize = page.getSize(); + } + + public Page toPage() { + return new PageImpl<>(content, PageRequest.of(pageNumber, pageSize), totalElements); + } + + public List getContent() { return content; } + public long getTotalElements() { return totalElements; } + public int getPageNumber() { return pageNumber; } + public int getPageSize() { return pageSize; } +} From 985954c1893e04389c04e75440bbb0a815e7cc6d Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 14:49:23 +0900 Subject: [PATCH 149/164] =?UTF-8?q?feat:=20Outbox=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A4=ED=8C=A8=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=9C=ED=96=89=20=EB=A1=9C=EC=A7=81=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 - `OutboxEvent`์— `OutboxStatus` ๋ฐ `retryCount` ํ•„๋“œ ์ถ”๊ฐ€๋กœ ์ƒํƒœ ๊ด€๋ฆฌ ์ง€์›. - ์‹คํŒจ ์ด๋ฒคํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ `markFailed` ๋ฉ”์„œ๋“œ ๊ตฌํ˜„. - `OutboxRelay` ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€๋กœ ์‹คํŒจ ์ด๋ฒคํŠธ ์žฌ๋ฐœํ–‰ ์Šค์ผ€์ค„๋ง ๋ฐ ์ฒ˜๋ฆฌ ๋กœ์ง ๊ตฌํ˜„. - ๊ด€๋ จ ์„œ๋น„์Šค ๋ฐ ํ•ธ๋“ค๋Ÿฌ์— ์‹คํŒจ ์ƒํƒœ ์—…๋ฐ์ดํŠธ ๋กœ์ง ํ†ตํ•ฉ (`markFailed` ํ˜ธ์ถœ ์ถ”๊ฐ€). --- .../event/DeadLetterQueueProcessor.java | 4 +- .../application/event/OutboxRelay.java | 49 +++++++++++++++++++ .../like/event/LikeEventOutboxHandler.java | 5 +- .../order/event/OrderEventOutboxHandler.java | 2 + .../event/ProductEventOutboxHandler.java | 2 + .../com/loopers/domain/event/OutboxEvent.java | 14 +++++- .../loopers/domain/event/OutboxService.java | 7 +++ .../loopers/domain/event/OutboxStatus.java | 5 ++ .../event/OutboxRepository.java | 5 ++ 9 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java b/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java index e7d29ea07..eb2749689 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/DeadLetterQueueProcessor.java @@ -4,15 +4,13 @@ import com.loopers.domain.event.DomainEvent; import com.loopers.domain.event.FailedEvent; import com.loopers.infrastructure.event.FailedEventRepository; +import java.util.List; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Component @RequiredArgsConstructor public class DeadLetterQueueProcessor { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java b/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java new file mode 100644 index 000000000..69d2d31ef --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/event/OutboxRelay.java @@ -0,0 +1,49 @@ +package com.loopers.application.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.event.OutboxEvent; +import com.loopers.domain.event.OutboxStatus; +import com.loopers.infrastructure.event.OutboxRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OutboxRelay { + + private final OutboxRepository outboxRepository; + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + @Scheduled(fixedDelay = 60000) + @Transactional + public void resendPendingEvents() { + List pendingEvents = outboxRepository.findTop10ByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( + List.of(OutboxStatus.INIT, OutboxStatus.FAILED), 5 + ); + + if (pendingEvents.isEmpty()) return; + + for (OutboxEvent outbox : pendingEvents) { + try { + Class eventClass = Class.forName(outbox.getEventType()); + Object originalEvent = objectMapper.readValue(outbox.getPayload(), eventClass); + + eventPublisher.publishEvent(originalEvent); + + log.info("[Outbox Relay] ์ด๋ฒคํŠธ ์žฌ๋ฐœํ–‰ ์„ฑ๊ณต: {}", outbox.getEventId()); + outbox.markPublished(); + + } catch (Exception e) { + log.error("[Outbox Relay] ์žฌ๋ฐœํ–‰ ์‹คํŒจ: {}", outbox.getEventId()); + outbox.markFailed(); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java index 7e9d0f249..1dffa89c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/event/LikeEventOutboxHandler.java @@ -2,7 +2,6 @@ import com.loopers.domain.event.OutboxService; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -10,8 +9,8 @@ @Component @RequiredArgsConstructor -@Slf4j public class LikeEventOutboxHandler { + private final KafkaTemplate kafkaTemplate; private final OutboxService outboxService; @@ -24,7 +23,7 @@ public void handle(LikeCreatedEvent event) { if (ex == null) { outboxService.markPublished(event.eventId()); } else { - log.error("์นดํ”„์นด ์ „์†ก ์‹คํŒจ: {}", ex.getMessage()); + outboxService.markFailed(event.eventId()); } }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java index 7ab03af7b..d47ec4fd4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java @@ -19,6 +19,8 @@ public void handle(OrderCreatedEvent event) { .whenComplete((result, ex) -> { if (ex == null) { outboxService.markPublished(event.eventId()); + } else { + outboxService.markFailed(event.eventId()); } }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java index 47efc5bed..f43bbdfc1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/event/ProductEventOutboxHandler.java @@ -25,6 +25,8 @@ public void handle(ProductViewEvent event) { .whenComplete((result, ex) -> { if (ex == null) { outboxService.markPublished(event.eventId()); + } else { + outboxService.markFailed(event.eventId()); } }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java index b42a9d094..aee43121a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxEvent.java @@ -2,6 +2,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -27,7 +29,10 @@ public class OutboxEvent { @Column(columnDefinition = "TEXT") private String payload; - private boolean published = false; + @Enumerated(EnumType.STRING) + private OutboxStatus status = OutboxStatus.INIT; + + private int retryCount = 0; private LocalDateTime createdAt = LocalDateTime.now(); public OutboxEvent(String eventId, String aggregateType, String aggregateId, String eventType, String payload) { @@ -39,6 +44,11 @@ public OutboxEvent(String eventId, String aggregateType, String aggregateId, Str } public void markPublished() { - this.published = true; + this.status = OutboxStatus.PUBLISHED; + } + + public void markFailed() { + this.status = OutboxStatus.FAILED; + this.retryCount++; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java index 7981b5c18..b5d8be2f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java @@ -40,4 +40,11 @@ public void markPublished(String eventId) { outboxRepository.save(event); }); } + + public void markFailed(String eventId) { + outboxRepository.findByEventId(eventId).ifPresent(event -> { + event.markFailed(); + outboxRepository.save(event); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java new file mode 100644 index 000000000..c8e667f5e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.event; + +public enum OutboxStatus { + INIT, PUBLISHED, FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java index 44ba09a99..3ff126c1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/event/OutboxRepository.java @@ -1,6 +1,7 @@ package com.loopers.infrastructure.event; import com.loopers.domain.event.OutboxEvent; +import com.loopers.domain.event.OutboxStatus; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,4 +12,8 @@ public interface OutboxRepository extends JpaRepository { Optional findByEventId(String eventId); + List findTop10ByStatusInAndRetryCountLessThanOrderByCreatedAtAsc( + List statuses, + int retryCount + ); } From 2a30ac71671dd7a8ba139221c6872055822f5cae Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 19 Dec 2025 15:57:39 +0900 Subject: [PATCH 150/164] =?UTF-8?q?chore:=20Kafka=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=93=80=EC=84=9C=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kafka ์„ค์ •์— enable.idempotence=true ๋ฐ acks=all ํ”„๋กœ๋“€์„œ ์˜ต์…˜ ์ถ”๊ฐ€ - OutboxService์—์„œ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” import ์ œ๊ฑฐ --- .../src/main/java/com/loopers/domain/event/OutboxService.java | 1 - modules/kafka/src/main/resources/kafka.yml | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java index b5d8be2f8..93257979e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/event/OutboxService.java @@ -1,6 +1,5 @@ package com.loopers.domain.event; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.loopers.infrastructure.event.OutboxRepository; import lombok.RequiredArgsConstructor; diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..2d8f3070e 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -15,6 +15,9 @@ spring: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer retries: 3 + acks: all + properties: + enable.idempotence: true consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer From e667da8684c58abbc3b6e3089a3dc21cd4ce3d40 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 23 Dec 2025 14:55:33 +0900 Subject: [PATCH 151/164] =?UTF-8?q?chore:=20DemoKafkaConsumer=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ๋ฏธ์‚ฌ์šฉ ํด๋ž˜์Šค(DemoKafkaConsumer) ์‚ญ์ œ๋กœ ์ฝ”๋“œ ์ •๋ฆฌ. - ๊ด€๋ จ Kafka ๋ฆฌ์Šค๋„ˆ ๋ฐ ์˜์กด์„ฑ ์ œ๊ฑฐ. --- .../consumer/DemoKafkaConsumer.java | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java deleted file mode 100644 index df5122d5a..000000000 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/DemoKafkaConsumer.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.consumer; - -import com.loopers.config.kafka.KafkaConfig; -import org.apache.kafka.clients.consumer.ConsumerRecord; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.support.Acknowledgment; -import org.springframework.stereotype.Component; - -import java.util.List; - -@Component -public class DemoKafkaConsumer { - @KafkaListener( - topics = {"${demo-kafka.test.topic-name}"}, - containerFactory = KafkaConfig.BATCH_LISTENER - ) - public void demoListener( - List> messages, - Acknowledgment acknowledgment - ){ - System.out.println(messages); - acknowledgment.acknowledge(); - } -} From 4a1aa525da89d50702efe939b65fdeb951020758 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Tue, 23 Dec 2025 21:17:14 +0900 Subject: [PATCH 152/164] =?UTF-8?q?refactor:=20OrderCreatedEvent=EA=B0=80?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EC=8A=A4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=ED=8F=AC=ED=95=A8=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ์˜ ์™ธ๋ถ€ ์„œ๋น„์Šค ์˜์กด์„ฑ ์ œ๊ฑฐ (Decoupling) - ์ด๋ฒคํŠธ ๊ฐ์ฒด๊ฐ€ ๋ฐœ์ƒ ์‹œ์ ์˜ ์ƒํƒœ(์žฌ๊ณ )๋ฅผ ์˜จ์ „ํžˆ ํ‘œํ˜„ํ•˜๋„๋ก ์ˆ˜์ • - ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ ์‹œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐ์ดํ„ฐ ๋ถˆ์ผ์น˜ ๊ฐ€๋Šฅ์„ฑ ๋ฐฉ์ง€ --- .../loopers/application/order/OrderFacade.java | 1 + .../order/event/OrderCreatedEvent.java | 16 +++++++++++++--- .../order/event/OrderSalesAggregateListener.java | 4 +--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 9d04e135d..1887391d6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -67,6 +67,7 @@ public OrderInfo placeOrder(PlaceOrder command) { order.getId(), user, orderItems, + products, finalPaymentAmount, command.paymentType(), command.cardType(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java index 1ed0595d5..e4ba58a1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java @@ -2,9 +2,12 @@ import com.loopers.domain.order.OrderItem; import com.loopers.domain.payment.PaymentType; +import com.loopers.domain.product.Product; import com.loopers.domain.user.User; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; public record OrderCreatedEvent( String eventId, @@ -18,7 +21,7 @@ public record OrderCreatedEvent( Long couponId ) { - public record OrderItemInfo(Long productId, int quantity) { + public record OrderItemInfo(Long productId, int quantity, int remainStock) { } @@ -26,6 +29,7 @@ public static OrderCreatedEvent of( Long orderId, User user, List orderItems, + List products, long finalAmount, PaymentType paymentType, String cardType, @@ -33,9 +37,15 @@ public static OrderCreatedEvent of( Long couponId ) { + Map stockMap = products.stream() + .collect(Collectors.toMap(Product::getId, Product::getStock)); + List itemInfos = orderItems.stream() - .map(item -> new OrderItemInfo(item.getProductId(), item.getQuantity())) - .toList(); + .map(item -> new OrderItemInfo( + item.getProductId(), + item.getQuantity(), + stockMap.getOrDefault(item.getProductId(), 0) + )).toList(); return new OrderCreatedEvent( UUID.randomUUID().toString(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java index 925696cd7..fdb03493d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java @@ -14,7 +14,6 @@ @RequiredArgsConstructor public class OrderSalesAggregateListener { - private final ProductService productService; private final OutboxService outboxService; private final ApplicationEventPublisher eventPublisher; @@ -24,11 +23,10 @@ public void handleOrderCreated(OrderCreatedEvent event) { event.items().forEach(item -> { - int currentStock = productService.getStock(item.productId()); ProductStockEvent kafkaEvent = ProductStockEvent.of( item.productId(), item.quantity(), - currentStock + item.remainStock() ); outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent); From 6de080bc79ff0492cd0e037a8db1dbc4ca6108ad Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 25 Dec 2025 10:23:25 +0900 Subject: [PATCH 153/164] =?UTF-8?q?feature:=20ProductStockEvent=EC=97=90?= =?UTF-8?q?=20=EA=B0=80=EA=B2=A9=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EC=A3=BC=EB=AC=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductStockEvent์— ๊ฐ€๊ฒฉ(price) ํ•„๋“œ ์ถ”๊ฐ€๋กœ ์ด๋ฒคํŠธ ์ •๋ณด ํ™•์žฅ - OrderCreatedEvent์—์„œ ์ƒํ’ˆ๊ณผ ๊ฐ€๊ฒฉ ๋ฐ์ดํ„ฐ๋ฅผ ํ•จ๊ป˜ ๋งคํ•‘ํ•˜๋„๋ก ์ˆ˜์ • - OrderSalesAggregateListener์—์„œ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์‹œ ๊ฐ€๊ฒฉ ์ •๋ณด๋ฅผ ๊ด€๋ฆฌํ•˜๋„๋ก ๊ฐœ์„  --- .../order/event/OrderCreatedEvent.java | 21 ++++++++++++------- .../event/OrderSalesAggregateListener.java | 4 ++-- .../consumer/MetricsEventConsumer.java | 2 ++ .../com/loopers/event/ProductStockEvent.java | 4 +++- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java index e4ba58a1d..cc0d3fb62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; public record OrderCreatedEvent( @@ -21,7 +22,7 @@ public record OrderCreatedEvent( Long couponId ) { - public record OrderItemInfo(Long productId, int quantity, int remainStock) { + public record OrderItemInfo(Long productId, int quantity, long price, int remainStock) { } @@ -37,15 +38,19 @@ public static OrderCreatedEvent of( Long couponId ) { - Map stockMap = products.stream() - .collect(Collectors.toMap(Product::getId, Product::getStock)); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); List itemInfos = orderItems.stream() - .map(item -> new OrderItemInfo( - item.getProductId(), - item.getQuantity(), - stockMap.getOrDefault(item.getProductId(), 0) - )).toList(); + .map(item -> { + Product product = productMap.get(item.getProductId()); + return new OrderItemInfo( + item.getProductId(), + item.getQuantity(), + product != null ? product.getPrice().getValue() : 0, + product != null ? product.getStock() : 0 + ); + }).toList(); return new OrderCreatedEvent( UUID.randomUUID().toString(), diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java index fdb03493d..35290afb1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java @@ -1,7 +1,6 @@ package com.loopers.application.order.event; import com.loopers.domain.event.OutboxService; -import com.loopers.domain.product.ProductService; import com.loopers.event.ProductStockEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -26,7 +25,8 @@ public void handleOrderCreated(OrderCreatedEvent event) { ProductStockEvent kafkaEvent = ProductStockEvent.of( item.productId(), item.quantity(), - item.remainStock() + item.remainStock(), + item.price() ); outboxService.saveEvent("STOCKS_METRICS", String.valueOf(item.productId()), kafkaEvent); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java index 5745f467f..cbde0d49e 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.consumer; import com.loopers.domain.metrics.ProductMetricsService; +import com.loopers.domain.rank.RankingService; import com.loopers.event.LikeCountEvent; import com.loopers.event.ProductStockEvent; import com.loopers.event.ProductViewEvent; @@ -17,6 +18,7 @@ public class MetricsEventConsumer { private final ProductMetricsService metricsService; + private final RankingService rankingService; @KafkaListener( topics = "catalog-events", diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java index 2b7c466ef..eeafd2c5f 100644 --- a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java @@ -7,14 +7,16 @@ public record ProductStockEvent( Long productId, int sellQuantity, int currentStock, + long price, long timestamp ) { - public static ProductStockEvent of(Long productId, int sellQuantity, int currentStock) { + public static ProductStockEvent of(Long productId, int sellQuantity, int currentStock, long price) { return new ProductStockEvent( UUID.randomUUID().toString(), productId, sellQuantity, currentStock, + price, System.currentTimeMillis() ); } From cd878d5154f365dca054db83d983f45225991cb3 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 25 Dec 2025 12:05:45 +0900 Subject: [PATCH 154/164] =?UTF-8?q?feature:=20=EC=A7=80=ED=91=9C=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?RankingService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ์ด๋ฒคํŠธ ํƒ€์ž„์Šคํƒฌํ”„ ํ•„๋“œ๋ฅผ `LocalDateTime`์œผ๋กœ ๋ณ€๊ฒฝํ•˜๊ณ  ์ด๋ฒคํŠธ ์ƒ์„ฑ ๋กœ์ง ์—…๋ฐ์ดํŠธ - `RankingService` ์ถ”๊ฐ€๋กœ ์ƒํ’ˆ ์ ์ˆ˜ ๊ด€๋ฆฌ ๋กœ์ง ๊ตฌํ˜„ (์กฐํšŒ์ˆ˜, ์ข‹์•„์š”, ํŒ๋งค๋Ÿ‰ ๊ฐ€์ค‘์น˜ ์ ์šฉ) - MetricsEventConsumer ํ™•์žฅ: ๋žญํ‚น ์ ์ˆ˜ ๊ณ„์‚ฐ ๋ฐ Redis ์—…๋ฐ์ดํŠธ ์ง€์› - Kafka ๋ฆฌ์Šค๋„ˆ ๋ฐ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ๋กœ์ง ๊ฐœ์„ , ์ด๋ฒคํŠธ๋ณ„ ์ ์ˆ˜ ์—…๋ฐ์ดํŠธ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€ - OutboxHandler ์ˆ˜์ •: ์ด๋ฒคํŠธ ํƒ€์ž… ๋ณ€๊ฒฝ ๋ฐ ์นดํƒˆ๋กœ๊ทธ ์ด๋ฒคํŠธ ๋ฐœํ–‰ ์ •๋ฆฌ --- .../order/event/OrderEventOutboxHandler.java | 5 ++- .../event/LikeCountAggregateListener.java | 4 +- .../loopers/domain/rank/RankingService.java | 38 +++++++++++++++++++ .../consumer/MetricsEventConsumer.java | 32 ++++++++++++---- .../com/loopers/event/LikeCountEvent.java | 15 +++++++- .../com/loopers/event/ProductStockEvent.java | 5 ++- .../com/loopers/event/ProductViewEvent.java | 5 ++- 7 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java index d47ec4fd4..a623e92cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderEventOutboxHandler.java @@ -1,6 +1,7 @@ package com.loopers.application.order.event; import com.loopers.domain.event.OutboxService; +import com.loopers.event.ProductStockEvent; import lombok.RequiredArgsConstructor; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; @@ -14,8 +15,8 @@ public class OrderEventOutboxHandler { private final OutboxService outboxService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(OrderCreatedEvent event) { - kafkaTemplate.send("order-events", String.valueOf(event.orderId()), event) + public void handle(ProductStockEvent event) { + kafkaTemplate.send("catalog-events", String.valueOf(event.productId()), event) .whenComplete((result, ex) -> { if (ex == null) { outboxService.markPublished(event.eventId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java index e0525938a..7d2ce0daa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/event/LikeCountAggregateListener.java @@ -2,8 +2,8 @@ import com.loopers.application.event.FailedEventStore; import com.loopers.application.like.event.LikeCreatedEvent; -import com.loopers.event.LikeCountEvent; import com.loopers.domain.product.ProductService; +import com.loopers.event.LikeCountEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.orm.ObjectOptimisticLockingFailureException; @@ -30,7 +30,7 @@ public void handleLikeCreatedEvent(LikeCreatedEvent event) { try { int updatedLikeCount = performAggregation(event); - eventPublisher.publishEvent(new LikeCountEvent( + eventPublisher.publishEvent(LikeCountEvent.of( event.eventId(), event.productId(), updatedLikeCount diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java new file mode 100644 index 000000000..9d01b7609 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java @@ -0,0 +1,38 @@ +package com.loopers.domain.rank; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RankingService { + + private final RedisTemplate redisTemplate; + + private static final String KEY_PREFIX = "ranking:all:"; + private static final double VIEW_WEIGHT = 0.1; + private static final double LIKE_WEIGHT = 0.2; + private static final double ORDER_WEIGHT = 0.6; + + public void addScore(Long productId, double baseScore, double weight, LocalDateTime dateTime) { + String dateKey = KEY_PREFIX + dateTime.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + double finalScore = baseScore * weight; + + redisTemplate.opsForZSet().incrementScore(dateKey, productId.toString(), finalScore); + redisTemplate.expire(dateKey, 2, TimeUnit.DAYS); + } + + public void addOrderScoresBatch(Map> updates) { + updates.forEach((dateKey, productScores) -> { + productScores.forEach((productId, score) -> { + redisTemplate.opsForZSet().incrementScore(dateKey, productId.toString(), score); + }); + redisTemplate.expire(dateKey, 2, TimeUnit.DAYS); + }); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java index cbde0d49e..7898a5870 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/MetricsEventConsumer.java @@ -5,9 +5,12 @@ import com.loopers.event.LikeCountEvent; import com.loopers.event.ProductStockEvent; import com.loopers.event.ProductViewEvent; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; @@ -24,9 +27,10 @@ public class MetricsEventConsumer { topics = "catalog-events", groupId = "metrics-group" ) - public void consumeLikeCount(ConsumerRecord record, Acknowledgment ack) { + public void consumeLikeCount(LikeCountEvent event, Acknowledgment ack) { try { - metricsService.processLikeCountEvent(record.value()); + metricsService.processLikeCountEvent(event); + rankingService.addScore(event.productId(), 1.0, 0.2, event.createdAt()); ack.acknowledge(); } catch (Exception e) { log.error("์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", e.getMessage()); @@ -40,6 +44,7 @@ public void consumeLikeCount(ConsumerRecord record, Ackn public void consumeProductView(ProductViewEvent event, Acknowledgment ack) { try { metricsService.processProductViewEvent(event); + rankingService.addScore(event.productId(), 1.0, 0.1, event.createdAt()); ack.acknowledge(); } catch (Exception e) { log.error("์กฐํšŒ์ˆ˜ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", event.eventId(), e); @@ -48,14 +53,27 @@ public void consumeProductView(ProductViewEvent event, Acknowledgment ack) { @KafkaListener( topics = "catalog-events", - groupId = "metrics-group" + groupId = "metrics-group", + containerFactory = "batchFactory" ) - public void consumeSalesCount(ProductStockEvent event, Acknowledgment ack) { + public void consumeSalesCount(List events, Acknowledgment ack) { try { - metricsService.processSalesCountEvent(event); + Map> rankingUpdates = new HashMap<>(); + + for (ProductStockEvent event : events) { + metricsService.processSalesCountEvent(event); + + String dateKey = "ranking:all:" + event.createdAt().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + double orderScore = (event.price() * event.sellQuantity()) * 0.6; + + rankingUpdates.computeIfAbsent(dateKey, k -> new HashMap<>()) + .merge(event.productId(), orderScore, Double::sum); + } + + rankingService.addOrderScoresBatch(rankingUpdates); ack.acknowledge(); } catch (Exception e) { - log.error("ํŒ๋งค๋Ÿ‰ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ: {}", event.eventId(), e); + log.error("ํŒ๋งค๋Ÿ‰ ๋ฉ”ํŠธ๋ฆญ ์ฒ˜๋ฆฌ ์‹คํŒจ", e); } } } diff --git a/modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java b/modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java index 541d50aab..ca571b92b 100644 --- a/modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/LikeCountEvent.java @@ -1,9 +1,20 @@ package com.loopers.event; +import java.time.LocalDateTime; + public record LikeCountEvent( - String eventId, + String eventId, Long productId, - int currentLikeCount + int currentLikeCount, + LocalDateTime createdAt ) { + public static LikeCountEvent of(String eventId, Long productId, int currentLikeCount) { + return new LikeCountEvent( + eventId, + productId, + currentLikeCount, + LocalDateTime.now() + ); + } } diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java index eeafd2c5f..73810226d 100644 --- a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java @@ -1,5 +1,6 @@ package com.loopers.event; +import java.time.LocalDateTime; import java.util.UUID; public record ProductStockEvent( @@ -8,7 +9,7 @@ public record ProductStockEvent( int sellQuantity, int currentStock, long price, - long timestamp + LocalDateTime createdAt ) { public static ProductStockEvent of(Long productId, int sellQuantity, int currentStock, long price) { return new ProductStockEvent( @@ -17,7 +18,7 @@ public static ProductStockEvent of(Long productId, int sellQuantity, int current sellQuantity, currentStock, price, - System.currentTimeMillis() + LocalDateTime.now() ); } } diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java index 55a0f03d4..6d1bf279e 100644 --- a/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/ProductViewEvent.java @@ -1,18 +1,19 @@ package com.loopers.event; +import java.time.LocalDateTime; import java.util.UUID; public record ProductViewEvent( String eventId, Long productId, - long timestamp + LocalDateTime createdAt ) { public static ProductViewEvent from(Long productId) { return new ProductViewEvent( UUID.randomUUID().toString(), productId, - System.currentTimeMillis() + LocalDateTime.now() ); } } From 425b980d403fb5257109df7f639e53fd26664aa3 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Thu, 25 Dec 2025 21:36:32 +0900 Subject: [PATCH 155/164] =?UTF-8?q?feature:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `RankingFacade` ๋ฐ `RankingService` ์ถ”๊ฐ€๋กœ ์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ ๋กœ์ง ๊ตฌํ˜„ - Redis ๊ธฐ๋ฐ˜ ๋žญํ‚น ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๋ฐ ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด ๋งคํ•‘ ์ง€์› - `RankingV1Controller`์™€ `RankingV1ApiSpec` ์ถ”๊ฐ€๋กœ REST API ์ œ๊ณต - `RankingV1Dto`์™€ `RankingInfo` ๊ตฌํ˜„์œผ๋กœ ์‘๋‹ต ๋ฐ์ดํ„ฐ ๊ตฌ์กฐํ™” --- .../application/rank/RankingFacade.java | 42 +++++++++++++++++++ .../loopers/application/rank/RankingInfo.java | 22 ++++++++++ .../loopers/domain/rank/RankingService.java | 30 +++++++++++++ .../interfaces/api/rank/RankingV1ApiSpec.java | 19 +++++++++ .../api/rank/RankingV1Controller.java | 35 ++++++++++++++++ .../interfaces/api/rank/RankingV1Dto.java | 25 +++++++++++ 6 files changed, 173 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java new file mode 100644 index 000000000..e736ad22c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java @@ -0,0 +1,42 @@ +package com.loopers.application.rank; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.rank.RankingService; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class RankingFacade { + + private final RankingService rankingService; + private final ProductService productService; + + public List getTopRankings(String date, int page, int size) { + List productIds = rankingService.getTopRankingIds(date, page, size); + + if (productIds.isEmpty()) { + return List.of(); + } + + List products = productService.getProducts(productIds); + Map productMap = products.stream() + .collect(Collectors.toMap(Product::getId, p -> p)); + + return IntStream.range(0, productIds.size()) + .mapToObj(i -> { + Long productId = productIds.get(i); + Product product = productMap.get(productId); + + int currentRank = ((page - 1) * size) + i + 1; + + return RankingInfo.of(product, currentRank); + }) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java new file mode 100644 index 000000000..0fb5e5ec9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java @@ -0,0 +1,22 @@ +package com.loopers.application.rank; + +import com.loopers.domain.product.Product; + +public record RankingInfo( + Long productId, + String productName, + Long price, + int stock, + int currentRank +) { + + public static RankingInfo of(Product product, int currentRank) { + return new RankingInfo( + product.getId(), + product.getName(), + product.getPrice().getValue(), + product.getStock(), + currentRank + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java new file mode 100644 index 000000000..7f0076f3a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.rank; + +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RankingService { + + private final RedisTemplate redisTemplate; + + public List getTopRankingIds(String date, int page, int size) { + String key = "ranking:all:" + date; + int start = (page - 1) * size; + int end = start + size - 1; + + Set rankedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); + + if (rankedIds == null || rankedIds.isEmpty()) { + return List.of(); + } + + return rankedIds.stream() + .map(Long::valueOf) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java new file mode 100644 index 000000000..15f1144f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.rank; + +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.rank.RankingV1Dto.RankingResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; + +@Tag(name = "Ranking API", description = "์‹ค์‹œ๊ฐ„ ์ƒํ’ˆ ๋žญํ‚น ๊ด€๋ จ API ์ž…๋‹ˆ๋‹ค.") +public interface RankingV1ApiSpec { + + @Operation(summary = "์‹ค์‹œ๊ฐ„ ๋žญํ‚น ์กฐํšŒ", description = "ํŠน์ • ๋‚ ์งœ์˜ ์ธ๊ธฐ ์ƒํ’ˆ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse> getRankings( + @Parameter(description = "์กฐํšŒ ๋‚ ์งœ (yyyyMMdd)", example = "20251225") String date, + @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") int page, + @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") int size + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java new file mode 100644 index 000000000..14dfee93d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.rank; + +import com.loopers.application.rank.RankingFacade; +import com.loopers.application.rank.RankingInfo; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.rank.RankingV1Dto.RankingResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingV1Controller implements RankingV1ApiSpec { + + private final RankingFacade rankingFacade; + + @GetMapping + @Override + public ApiResponse> getRankings( + @RequestParam(value = "date") String date, + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "20") int size + ) { + List infos = rankingFacade.getTopRankings(date, page, size); + List response = infos.stream() + .map(RankingV1Dto.RankingResponse::from) + .toList(); + + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java new file mode 100644 index 000000000..7413b94cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.rank; + +import com.loopers.application.rank.RankingInfo; + +public record RankingV1Dto() { + + public record RankingResponse( + Long productId, + String productName, + Long price, + int stock, + int currentRank + ) { + + public static RankingResponse from(RankingInfo info) { + return new RankingResponse( + info.productId(), + info.productName(), + info.price(), + info.stock(), + info.currentRank() + ); + } + } +} From 6fe302501f843bc477e4e43e2d5299e9bd7172a0 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 26 Dec 2025 09:44:10 +0900 Subject: [PATCH 156/164] =?UTF-8?q?feature:=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `RankingService` ๋ฉ”์„œ๋“œ `getProductRank` ๊ตฌํ˜„์œผ๋กœ Redis ๊ธฐ๋ฐ˜ ์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ ์ง€์› - ์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ ์‹œ ํ˜„์žฌ ๋žญํ‚น ์ •๋ณด๋ฅผ ํฌํ•จํ•˜๋„๋ก `ProductInfo` ๋ฐ `ProductFacade` ์ˆ˜์ • - ๊ธฐ์กด `RankingService` ํŒจํ‚ค์ง€ ์œ„์น˜๋ฅผ `infrastructure`๋กœ ๋ณ€๊ฒฝํ•˜์—ฌ ๊ด€๋ฆฌ ๊ฐœ์„  --- .../loopers/application/product/ProductFacade.java | 8 ++++++-- .../loopers/application/product/ProductInfo.java | 11 +++++++---- .../com/loopers/application/rank/RankingFacade.java | 2 +- .../rank/RankingService.java | 13 ++++++++++++- 4 files changed, 26 insertions(+), 8 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/{domain => infrastructure}/rank/RankingService.java (62%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index fa44b983b..28425a26f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -3,6 +3,7 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; +import com.loopers.infrastructure.rank.RankingService; import com.loopers.event.ProductViewEvent; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -16,6 +17,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final RankingService rankingService; private final ApplicationEventPublisher eventPublisher; public Page getProductsInfo(Pageable pageable) { @@ -23,7 +25,7 @@ public Page getProductsInfo(Pageable pageable) { return products.map(product -> { String brandName = brandService.getBrand(product.getBrandId()) .getName(); - return ProductInfo.from(product, brandName); + return ProductInfo.from(product, brandName, null); }); } @@ -32,9 +34,11 @@ public ProductInfo getProductInfo(long id) { String brandName = brandService.getBrand(product.getBrandId()) .getName(); + Integer currentRank = rankingService.getProductRank(id); + eventPublisher.publishEvent(ProductViewEvent.from(id)); - return ProductInfo.from(product, brandName); + return ProductInfo.from(product, brandName, currentRank); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index d5a6e82b8..894221771 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -8,7 +8,8 @@ public record ProductInfo( String name, Money price, String brandName, - int likeCount + int likeCount, + Integer currentRank ) { public static ProductInfo from(Product product) { return new ProductInfo( @@ -16,17 +17,19 @@ public static ProductInfo from(Product product) { product.getName(), product.getPrice(), null, - product.getLikeCount() + product.getLikeCount(), + null ); } - public static ProductInfo from(Product product, String brandName) { + public static ProductInfo from(Product product, String brandName, Integer currentRank) { return new ProductInfo( product.getId(), product.getName(), product.getPrice(), brandName, - product.getLikeCount() + product.getLikeCount(), + currentRank ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java index e736ad22c..f5c573459 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java @@ -2,7 +2,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.domain.rank.RankingService; +import com.loopers.infrastructure.rank.RankingService; import java.util.List; import java.util.Map; import java.util.stream.Collectors; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java similarity index 62% rename from apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java index 7f0076f3a..2caeb1cd3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/rank/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java @@ -1,5 +1,7 @@ -package com.loopers.domain.rank; +package com.loopers.infrastructure.rank; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Set; import lombok.RequiredArgsConstructor; @@ -27,4 +29,13 @@ public List getTopRankingIds(String date, int page, int size) { .map(Long::valueOf) .toList(); } + + public Integer getProductRank(Long productId) { + String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String key = "ranking:all:" + today; + + Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); + + return (rank != null) ? rank.intValue() + 1 : null; + } } From db4ed51232cd08d29b77a62c9cc22b4323ccf493 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 26 Dec 2025 15:12:45 +0900 Subject: [PATCH 157/164] =?UTF-8?q?feat:=20=EC=BD=9C=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=ED=8A=B8=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Score=20Carry-Over=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `carryOverRanking` ๋ฉ”์„œ๋“œ๋ฅผ `RankingService`์— ์ถ”๊ฐ€ํ•˜์—ฌ ๋žญํ‚น ๋ฐ์ดํ„ฐ ์ด์›” ์ง€์› - ์ด์›” ์ž‘์—… ์ž๋™ํ™”๋ฅผ ์œ„ํ•œ `RankingScheduler` ๊ตฌํ˜„ ๋ฐ Cron ์Šค์ผ€์ค„ ์„ค์ • - Redis ์—ฐ์‚ฐ์„ ํ™œ์šฉํ•œ ์ด์›” ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๋ฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์„ค์ • ๋กœ์ง ์ถ”๊ฐ€ --- .../application/rank/RankingScheduler.java | 35 +++++++++++++++++++ .../infrastructure/rank/RankingService.java | 13 +++++++ 2 files changed, 48 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java new file mode 100644 index 000000000..e264f9b84 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java @@ -0,0 +1,35 @@ +package com.loopers.application.rank; + +import com.loopers.infrastructure.rank.RankingService; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final RankingService rankingService; + + @Scheduled(cron = "0 50 23 * * *") + public void scheduleRankingCarryOver() { + LocalDateTime now = LocalDateTime.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd"); + + String today = now.format(formatter); + String tomorrow = now.plusDays(1).format(formatter); + + log.info("Starting Ranking Carry-Over: {} -> {}", today, tomorrow); + + try { + rankingService.carryOverRanking(today, tomorrow, 0.1); + log.info("Ranking Carry-Over completed successfully."); + } catch (Exception e) { + log.error("Ranking Carry-Over failed", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java index 2caeb1cd3..9c8b33cf2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java @@ -4,7 +4,10 @@ import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @@ -38,4 +41,14 @@ public Integer getProductRank(Long productId) { return (rank != null) ? rank.intValue() + 1 : null; } + + public void carryOverRanking(String sourceDate, String targetDate, double weight) { + String sourceKey = "ranking:all:" + sourceDate; + String targetKey = "ranking:all:" + targetDate; + + redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(), targetKey, + Aggregate.SUM, Weights.of(weight)); + + redisTemplate.expire(targetKey, 2, TimeUnit.DAYS); + } } From ee1641373ce4fead22ad5162b9fac85242c90c03 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 31 Dec 2025 15:05:50 +0900 Subject: [PATCH 158/164] =?UTF-8?q?refactor:=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EB=9E=AD=ED=82=B9=20=ED=82=A4=20=EC=83=9D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20RankingKeyGenerator=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20commerce-streamer=EC=9D=98=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20RankingService=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ๋™์ ์ด๊ณ  ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋žญํ‚น ํ‚ค ์ƒ์„ฑ์„ ์œ„ํ•ด RankingKeyGenerator ์ธํ„ฐํŽ˜์ด์Šค์™€ RedisRankingKeyGenerator ๊ตฌํ˜„์ฒด ๋„์ž… - ์œ ์ง€๋ณด์ˆ˜์„ฑ ํ–ฅ์ƒ์„ ์œ„ํ•ด RankingService์—์„œ RankingKeyGenerator๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ๋ฆฌํŒฉํ† ๋ง - commerce-api์— ์žˆ๋˜ ์ค‘๋ณต carryOverRanking ๋กœ์ง ์ œ๊ฑฐ ํ›„ commerce-streamer ๋ชจ๋“ˆ๋กœ ํ†ตํ•ฉ - ๋ฆฌํŒฉํ† ๋ง๋œ RankingService๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก RankingScheduler ์ˆ˜์ • --- .../infrastructure/rank/RankingService.java | 10 ---------- .../application/rank/RankingScheduler.java | 2 +- .../domain/rank/RankingKeyGenerator.java | 9 +++++++++ .../loopers/domain/rank/RankingService.java | 18 +++++++++++++++--- .../domain/rank/RedisRankingKeyGenerator.java | 14 ++++++++++++++ 5 files changed, 39 insertions(+), 14 deletions(-) rename apps/{commerce-api => commerce-streamer}/src/main/java/com/loopers/application/rank/RankingScheduler.java (94%) create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java create mode 100644 apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java index 9c8b33cf2..dfe271f20 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java @@ -41,14 +41,4 @@ public Integer getProductRank(Long productId) { return (rank != null) ? rank.intValue() + 1 : null; } - - public void carryOverRanking(String sourceDate, String targetDate, double weight) { - String sourceKey = "ranking:all:" + sourceDate; - String targetKey = "ranking:all:" + targetDate; - - redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(), targetKey, - Aggregate.SUM, Weights.of(weight)); - - redisTemplate.expire(targetKey, 2, TimeUnit.DAYS); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java b/apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java rename to apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java index e264f9b84..3c60aab26 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingScheduler.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/application/rank/RankingScheduler.java @@ -1,6 +1,6 @@ package com.loopers.application.rank; -import com.loopers.infrastructure.rank.RankingService; +import com.loopers.domain.rank.RankingService; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java new file mode 100644 index 000000000..91dc4b64f --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingKeyGenerator.java @@ -0,0 +1,9 @@ +package com.loopers.domain.rank; + +import org.springframework.stereotype.Component; + +public interface RankingKeyGenerator { + + String generateDailyKey(String date); +} + diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java index 9d01b7609..0be59501b 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RankingService.java @@ -2,9 +2,12 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.zset.Aggregate; +import org.springframework.data.redis.connection.zset.Weights; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; @@ -13,14 +16,13 @@ public class RankingService { private final RedisTemplate redisTemplate; - - private static final String KEY_PREFIX = "ranking:all:"; + private final RankingKeyGenerator rankingKeyGenerator; private static final double VIEW_WEIGHT = 0.1; private static final double LIKE_WEIGHT = 0.2; private static final double ORDER_WEIGHT = 0.6; public void addScore(Long productId, double baseScore, double weight, LocalDateTime dateTime) { - String dateKey = KEY_PREFIX + dateTime.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String dateKey = rankingKeyGenerator.generateDailyKey(dateTime.format(DateTimeFormatter.ofPattern("yyyyMMdd"))); double finalScore = baseScore * weight; redisTemplate.opsForZSet().incrementScore(dateKey, productId.toString(), finalScore); @@ -35,4 +37,14 @@ public void addOrderScoresBatch(Map> updates) { redisTemplate.expire(dateKey, 2, TimeUnit.DAYS); }); } + + public void carryOverRanking(String sourceDate, String targetDate, double weight) { + String sourceKey = rankingKeyGenerator.generateDailyKey(sourceDate); + String targetKey = rankingKeyGenerator.generateDailyKey(targetDate); + + redisTemplate.opsForZSet().unionAndStore(sourceKey, List.of(), targetKey, + Aggregate.SUM, Weights.of(weight)); + + redisTemplate.expire(targetKey, 2, TimeUnit.DAYS); + } } diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java new file mode 100644 index 000000000..d44cc4606 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/rank/RedisRankingKeyGenerator.java @@ -0,0 +1,14 @@ +package com.loopers.domain.rank; + +import org.springframework.stereotype.Component; + +@Component +public class RedisRankingKeyGenerator implements RankingKeyGenerator { + + private static final String DAILY_RANKING_PREFIX = "ranking:all:"; + + @Override + public String generateDailyKey(String date) { + return DAILY_RANKING_PREFIX + date; + } +} From 21dc4d2e58bfb9c043612bb9c1a82bca837c95f4 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Wed, 31 Dec 2025 15:15:58 +0900 Subject: [PATCH 159/164] =?UTF-8?q?refactor:=20=EB=9E=AD=ED=82=B9=20API=20?= =?UTF-8?q?=EC=9D=98=20=EC=83=81=ED=92=88=20=EC=9D=91=EB=8B=B5=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=EC=97=90=20=EC=9E=AC=EA=B3=A0=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=8A=B9=EC=A0=95=20=EA=B2=B0=EA=B3=BC=EB=A7=8C=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/rank/RankingInfo.java | 4 ++-- .../java/com/loopers/interfaces/api/rank/RankingV1Dto.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java index 0fb5e5ec9..f01f5030e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java @@ -6,7 +6,7 @@ public record RankingInfo( Long productId, String productName, Long price, - int stock, + boolean isSoldOut, int currentRank ) { @@ -15,7 +15,7 @@ public static RankingInfo of(Product product, int currentRank) { product.getId(), product.getName(), product.getPrice().getValue(), - product.getStock(), + product.getStock() <= 0, currentRank ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java index 7413b94cb..6a48c34ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Dto.java @@ -8,7 +8,7 @@ public record RankingResponse( Long productId, String productName, Long price, - int stock, + boolean isSoldOut, int currentRank ) { @@ -17,7 +17,7 @@ public static RankingResponse from(RankingInfo info) { info.productId(), info.productName(), info.price(), - info.stock(), + info.isSoldOut(), info.currentRank() ); } From d9322e11398986400b8751cbc63fe7379653a6ac Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 2 Jan 2026 14:40:52 +0900 Subject: [PATCH 160/164] =?UTF-8?q?feat:=20Order=20=EB=B0=8F=20Stock=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EC=97=90=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EB=AA=85=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=9E=AD=ED=82=B9=20=EB=A7=A4=ED=95=91=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ProductStockEvent`์™€ `OrderCreatedEvent`์˜ ์ƒํ’ˆ๋ช…(`productName`) ํ•„๋“œ ์ถ”๊ฐ€๋กœ ์ด๋ฒคํŠธ ์ •๋ณด๋ฅผ ํ™•์žฅ - ๋žญํ‚น ์—”ํ‹ฐํ‹ฐ ๋งคํ•‘์„ ์œ„ํ•œ `RankingInfo` ์ƒ์„ฑ ๋ฉ”์„œ๋“œ ํ™•์žฅ (์ฃผ๊ฐ„ ๋ฐ ์›”๊ฐ„ ๋žญํ‚น ์ง€์›) --- .../order/event/OrderCreatedEvent.java | 4 ++-- .../event/OrderSalesAggregateListener.java | 1 + .../loopers/application/rank/RankingInfo.java | 22 +++++++++++++++++++ .../com/loopers/event/ProductStockEvent.java | 4 +++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java index cc0d3fb62..f1f4a55c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderCreatedEvent.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.Function; import java.util.stream.Collectors; public record OrderCreatedEvent( @@ -22,7 +21,7 @@ public record OrderCreatedEvent( Long couponId ) { - public record OrderItemInfo(Long productId, int quantity, long price, int remainStock) { + public record OrderItemInfo(Long productId, String productName, int quantity, long price, int remainStock) { } @@ -46,6 +45,7 @@ public static OrderCreatedEvent of( Product product = productMap.get(item.getProductId()); return new OrderItemInfo( item.getProductId(), + product != null ? product.getName() : "Unknown", item.getQuantity(), product != null ? product.getPrice().getValue() : 0, product != null ? product.getStock() : 0 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java index 35290afb1..a06283d1d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/event/OrderSalesAggregateListener.java @@ -24,6 +24,7 @@ public void handleOrderCreated(OrderCreatedEvent event) { ProductStockEvent kafkaEvent = ProductStockEvent.of( item.productId(), + item.productName(), item.quantity(), item.remainStock(), item.price() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java index f01f5030e..90810b6f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingInfo.java @@ -1,6 +1,8 @@ package com.loopers.application.rank; import com.loopers.domain.product.Product; +import com.loopers.domain.rank.monthly.MonthlyRankingMV; +import com.loopers.domain.rank.weekly.WeeklyRankingMV; public record RankingInfo( Long productId, @@ -19,4 +21,24 @@ public static RankingInfo of(Product product, int currentRank) { currentRank ); } + + public static RankingInfo from(WeeklyRankingMV mv) { + return new RankingInfo( + mv.getProductId(), + mv.getProductName(), + mv.getPrice(), + mv.isSoldOut(), + mv.getCurrentRank() + ); + } + + public static RankingInfo from(MonthlyRankingMV mv) { + return new RankingInfo( + mv.getProductId(), + mv.getProductName(), + mv.getPrice(), + mv.isSoldOut(), + mv.getCurrentRank() + ); + } } diff --git a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java index 73810226d..3b0678d16 100644 --- a/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java +++ b/modules/kafka/src/main/java/com/loopers/event/ProductStockEvent.java @@ -6,15 +6,17 @@ public record ProductStockEvent( String eventId, Long productId, + String productName, int sellQuantity, int currentStock, long price, LocalDateTime createdAt ) { - public static ProductStockEvent of(Long productId, int sellQuantity, int currentStock, long price) { + public static ProductStockEvent of(Long productId, String productName, int sellQuantity, int currentStock, long price) { return new ProductStockEvent( UUID.randomUUID().toString(), productId, + productName, sellQuantity, currentStock, price, From e0ef3260e01240ad4003254e1ffcc75f04b5c1fe Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 2 Jan 2026 14:43:53 +0900 Subject: [PATCH 161/164] =?UTF-8?q?feat:=20ProductMetrics=EC=97=90=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=A0=95=EB=B3=B4=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `productName`, `price`, `isSoldOut` ํ•„๋“œ ์ถ”๊ฐ€๋กœ ์ƒํ’ˆ ๋ฐ์ดํ„ฐ ํ™•์žฅ - `updateProductSnapshot` ๋ฉ”์„œ๋“œ ๊ตฌํ˜„์œผ๋กœ ์ƒํ’ˆ ์ƒํƒœ ๋™๊ธฐํ™” ์ง€์› - ํŒจํ‚ค์ง€ ๊ตฌ์กฐ ๊ฐœ์„ : `ProductMetrics`๋ฅผ `metrics`์—์„œ ์ตœ์ƒ์œ„ `domain`์œผ๋กœ ์ด๋™ - `ProductMetricsService`์—์„œ ์ƒํ’ˆ ์ •๋ณด ์—…๋ฐ์ดํŠธ ๋กœ์ง ํ†ตํ•ฉ ๋ฐ Redis ์บ์‹œ ์ฒ˜๋ฆฌ ๊ฐ•ํ™” --- .../domain/metrics/ProductMetricsService.java | 7 +++++++ .../infrastructure/ProductMetricsRepository.java | 2 +- .../java/com/loopers/domain}/ProductMetrics.java | 12 +++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) rename {apps/commerce-streamer/src/main/java/com/loopers/domain/metrics => modules/jpa/src/main/java/com/loopers/domain}/ProductMetrics.java (75%) diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java index b259518c2..3b9dff8cc 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetricsService.java @@ -1,6 +1,7 @@ package com.loopers.domain.metrics; import com.loopers.core.cache.RedisCacheHandler; +import com.loopers.domain.ProductMetrics; import com.loopers.domain.event.EventHandled; import com.loopers.event.LikeCountEvent; import com.loopers.event.ProductStockEvent; @@ -50,6 +51,12 @@ public void processSalesCountEvent(ProductStockEvent event) { metrics.addSalesCount(event.sellQuantity()); + metrics.updateProductSnapshot( + event.productName(), + event.price(), + event.currentStock() + ); + if (event.currentStock() <= 0) { redisCacheHandler.delete("product:detail:" + event.productId()); redisCacheHandler.deleteByPattern("product:list"); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java index b8732b5fd..ef35ff6da 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/ProductMetricsRepository.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure; -import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.domain.ProductMetrics; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java similarity index 75% rename from apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java rename to modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java index a519fea7f..8c3bec8b5 100644 --- a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java +++ b/modules/jpa/src/main/java/com/loopers/domain/ProductMetrics.java @@ -1,4 +1,4 @@ -package com.loopers.domain.metrics; +package com.loopers.domain; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -21,6 +21,9 @@ public class ProductMetrics { private int viewCount = 0; private int salesCount = 0; + private String productName; + private Long price; + private boolean isSoldOut; private LocalDateTime updatedAt; public ProductMetrics(Long productId) { @@ -44,4 +47,11 @@ public void addSalesCount(int quantity) { this.salesCount += quantity; this.updatedAt = LocalDateTime.now(); } + + public void updateProductSnapshot(String productName, long price, int currentStock) { + this.productName = productName; + this.price = price; + this.isSoldOut = (currentStock <= 0); + this.updatedAt = LocalDateTime.now(); + } } From 6f4a795a14a3329620711dd0ad2cec1b103d11eb Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 2 Jan 2026 14:46:39 +0900 Subject: [PATCH 162/164] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=C2=B7?= =?UTF-8?q?=EC=9B=94=EA=B0=84=20=EB=9E=AD=ED=82=B9=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20RankingService=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=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 - RankingService ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๊ตฌํ˜„ํ•œ RankingServiceImpl ์ถ”๊ฐ€ - ์ฃผ๊ฐ„ยท์›”๊ฐ„ ๋žญํ‚น ์กฐํšŒ๋ฅผ ์œ„ํ•œ getWeeklyRankings, getMonthlyRankings ๋ฉ”์„œ๋“œ ๊ตฌํ˜„ - ์ž…๋ ฅ๊ฐ’์— ๋”ฐ๋ผ ๋žญํ‚น ํƒ€์ž…์„ ๋™์ ์œผ๋กœ ๋ถ„๊ธฐํ•˜๋„๋ก RankingFacade ๊ฐœ์„  - DAILY, WEEKLY, MONTHLY ๋žญํ‚น ํƒ€์ž…์„ ์ง€์›ํ•˜๋„๋ก RankingV1ApiSpec ๋ฐ RankingV1Controller ํ™•์žฅ --- .../application/product/ProductFacade.java | 2 +- .../application/rank/RankingFacade.java | 8 +++ .../infrastructure/rank/RankingService.java | 41 ++---------- .../rank/RankingServiceImpl.java | 65 +++++++++++++++++++ .../interfaces/api/rank/RankingV1ApiSpec.java | 1 + .../api/rank/RankingV1Controller.java | 3 +- 6 files changed, 83 insertions(+), 37 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 28425a26f..01916ddc0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -3,8 +3,8 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.infrastructure.rank.RankingService; import com.loopers.event.ProductViewEvent; +import com.loopers.infrastructure.rank.RankingService; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java index f5c573459..88b063499 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/rank/RankingFacade.java @@ -39,4 +39,12 @@ public List getTopRankings(String date, int page, int size) { }) .toList(); } + + public List getRankings(String type, String date, int page, int size) { + return switch (type.toUpperCase()) { + case "WEEKLY" -> rankingService.getWeeklyRankings(date, page, size); + case "MONTHLY" -> rankingService.getMonthlyRankings(date, page, size); + default -> getTopRankings(date, page, size); + }; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java index dfe271f20..276204504 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingService.java @@ -1,44 +1,15 @@ package com.loopers.infrastructure.rank; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; +import com.loopers.application.rank.RankingInfo; import java.util.List; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.connection.zset.Aggregate; -import org.springframework.data.redis.connection.zset.Weights; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Component; -@Component -@RequiredArgsConstructor -public class RankingService { +public interface RankingService { - private final RedisTemplate redisTemplate; + List getTopRankingIds(String date, int page, int size); - public List getTopRankingIds(String date, int page, int size) { - String key = "ranking:all:" + date; - int start = (page - 1) * size; - int end = start + size - 1; + List getWeeklyRankings(String date, int page, int size); - Set rankedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); + List getMonthlyRankings(String date, int page, int size); - if (rankedIds == null || rankedIds.isEmpty()) { - return List.of(); - } - - return rankedIds.stream() - .map(Long::valueOf) - .toList(); - } - - public Integer getProductRank(Long productId) { - String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String key = "ranking:all:" + today; - - Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); - - return (rank != null) ? rank.intValue() + 1 : null; - } + Integer getProductRank(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java new file mode 100644 index 000000000..589896bfb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/rank/RankingServiceImpl.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure.rank; + +import com.loopers.application.rank.RankingInfo; +import com.loopers.domain.rank.monthly.MonthlyRankingMVRepository; +import com.loopers.domain.rank.weekly.WeeklyRankingMVRepository; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class RankingServiceImpl implements RankingService { + + private final RedisTemplate redisTemplate; + private final WeeklyRankingMVRepository weeklyRepository; + private final MonthlyRankingMVRepository monthlyRepository; + + @Override + public List getTopRankingIds(String date, int page, int size) { + String key = "ranking:all:" + date; + int start = (page - 1) * size; + int end = start + size - 1; + + Set rankedIds = redisTemplate.opsForZSet().reverseRange(key, start, end); + + if (rankedIds == null || rankedIds.isEmpty()) { + return List.of(); + } + + return rankedIds.stream() + .map(Long::valueOf) + .toList(); + } + + @Override + public List getWeeklyRankings(String date, int page, int size) { + return weeklyRepository.findByBaseDateOrderByCurrentRankAsc(date, PageRequest.of(page - 1, size)) + .stream() + .map(RankingInfo::from) + .toList(); + } + + @Override + public List getMonthlyRankings(String date, int page, int size) { + return monthlyRepository.findByBaseDateOrderByCurrentRankAsc(date, PageRequest.of(page - 1, size)) + .stream() + .map(RankingInfo::from) + .toList(); + } + + @Override + public Integer getProductRank(Long productId) { + String today = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String key = "ranking:all:" + today; + + Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(productId)); + + return (rank != null) ? rank.intValue() + 1 : null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java index 15f1144f8..52419f988 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1ApiSpec.java @@ -12,6 +12,7 @@ public interface RankingV1ApiSpec { @Operation(summary = "์‹ค์‹œ๊ฐ„ ๋žญํ‚น ์กฐํšŒ", description = "ํŠน์ • ๋‚ ์งœ์˜ ์ธ๊ธฐ ์ƒํ’ˆ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") ApiResponse> getRankings( + @Parameter(description = "๋žญํ‚น ํƒ€์ž… (DAILY, WEEKLY, MONTHLY)", example = "WEEKLY") String type, @Parameter(description = "์กฐํšŒ ๋‚ ์งœ (yyyyMMdd)", example = "20251225") String date, @Parameter(description = "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ") int page, @Parameter(description = "ํŽ˜์ด์ง€ ํฌ๊ธฐ") int size diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java index 14dfee93d..ba5c547fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/rank/RankingV1Controller.java @@ -21,11 +21,12 @@ public class RankingV1Controller implements RankingV1ApiSpec { @GetMapping @Override public ApiResponse> getRankings( + @RequestParam(value = "type", defaultValue = "DAILY") String type, @RequestParam(value = "date") String date, @RequestParam(value = "page", defaultValue = "1") int page, @RequestParam(value = "size", defaultValue = "20") int size ) { - List infos = rankingFacade.getTopRankings(date, page, size); + List infos = rankingFacade.getRankings(type, date, page, size); List response = infos.stream() .map(RankingV1Dto.RankingResponse::from) .toList(); From 384ec825c4c63396157e860ddce920c2298d3b73 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 2 Jan 2026 15:07:06 +0900 Subject: [PATCH 163/164] =?UTF-8?q?feat:=20=EC=A3=BC=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EB=B0=B0=EC=B9=98=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=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 - ์ฃผ๊ฐ„ ๋žญํ‚น ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ(WeeklyRankingMV, WeeklyRankingWork) ์ถ”๊ฐ€ - WeeklyRankingJobConfig ๋ฐ RankingChunkConfig๋กœ ๋ฐฐ์น˜ ์žก ๊ตฌ์„ฑ - WeeklyRankingProcessor ๋ฐ Tasklet์„ ํ†ตํ•ด ์ ์ˆ˜ ๊ณ„์‚ฐ, ๋ฐ์ดํ„ฐ ์ค€๋น„ ๋ฐ ์Šค์™‘ ์ฒ˜๋ฆฌ ๊ตฌํ˜„ - ์Šค์ผ€์ค„๋ง์„ ํ†ตํ•ด ๋ฐฐ์น˜ ์žก ์ž๋™ ์‹คํ–‰ (๋งค์ฃผ ์›”์š”์ผ 2์‹œ ์„ค์ •) - MonthlyRankingJob ์Šค์ผˆ๋ ˆํ†ค ์ถ”๊ฐ€, ํ–ฅํ›„ ํ™•์žฅ ๊ณ ๋ ค --- .../com/loopers/CommerceBatchApplication.java | 2 + .../batch/job/ranking/RankingChunkConfig.java | 65 ++++++++++++++++ .../job/ranking/WeeklyRankingJobConfig.java | 75 +++++++++++++++++++ .../ranking/scheduler/RankingScheduler.java | 59 +++++++++++++++ .../ranking/step/RankingPrepareTasklet.java | 24 ++++++ .../ranking/step/RankingTableSwapTasklet.java | 40 ++++++++++ .../step/weekly/WeeklyRankingProcessor.java | 30 ++++++++ .../domain/rank/monthly/ProductSnapshot.java | 19 +++++ .../domain/rank/weekly/WeeklyRankingMV.java | 53 +++++++++++++ .../weekly/WeeklyRankingMVRepository.java | 12 +++ .../domain/rank/weekly/WeeklyRankingWork.java | 30 ++++++++ .../weekly/WeeklyRankingWorkRepository.java | 9 +++ 12 files changed, 418 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java index e5005c373..d99b05238 100644 --- a/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java +++ b/apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java @@ -6,7 +6,9 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import java.util.TimeZone; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication public class CommerceBatchApplication { diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java new file mode 100644 index 000000000..f78d8d4f6 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/RankingChunkConfig.java @@ -0,0 +1,65 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.weekly.WeeklyRankingProcessor; +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDateTime; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.batch.item.database.builder.JpaItemWriterBuilder; +import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RankingChunkConfig { + + private final WeeklyRankingProcessor weeklyRankingProcessor; + private final EntityManagerFactory emf; + + @Bean + @StepScope + public JpaPagingItemReader rankingReader() { + return new JpaPagingItemReaderBuilder() + .name("rankingReader") + .entityManagerFactory(emf) + .queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate") + .parameterValues(Map.of("startDate", LocalDateTime.now().minusDays(7))) + .pageSize(100) + .build(); + } + + @Bean + public ItemProcessor rankingProcessor() { + return weeklyRankingProcessor; + } + + @Bean + @StepScope + public JpaItemWriter rankingWriter() { + return new JpaItemWriterBuilder() + .entityManagerFactory(emf) + .build(); + } + + @Bean + @StepScope + public JpaPagingItemReader monthlyRankingReader( + @Value("#{jobParameters['startDate']}") String startDate + ) { + return new JpaPagingItemReaderBuilder() + .name("monthlyRankingReader") + .entityManagerFactory(emf) + .queryString("SELECT m FROM ProductMetrics m WHERE m.updatedAt >= :startDate") + .parameterValues(Map.of("startDate", LocalDateTime.parse(startDate))) + .pageSize(100) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java new file mode 100644 index 000000000..c87e7a46c --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/WeeklyRankingJobConfig.java @@ -0,0 +1,75 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.RankingPrepareTasklet; +import com.loopers.batch.job.ranking.step.RankingTableSwapTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = WeeklyRankingJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class WeeklyRankingJobConfig { + + public static final String JOB_NAME = "weeklyRankingJob"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final PlatformTransactionManager transactionManager; + + private final RankingPrepareTasklet prepareTasklet; + private final RankingTableSwapTasklet tableSwapTasklet; + + private final ItemReader rankingReader; + private final ItemProcessor rankingProcessor; + private final ItemWriter rankingWriter; + + @Bean(JOB_NAME) + public Job weeklyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(prepareStep()) + .next(calculationStep()) + .next(tableSwapStep()) + .listener(jobListener) + .build(); + } + + @Bean + public Step prepareStep() { + return new StepBuilder("prepareStep", jobRepository) + .tasklet(prepareTasklet, transactionManager) + .build(); + } + + @Bean + public Step calculationStep() { + return new StepBuilder("calculationStep", jobRepository) + .chunk(100, transactionManager) + .reader(rankingReader) + .processor(rankingProcessor) + .writer(rankingWriter) + .build(); + } + + @Bean + public Step tableSwapStep() { + return new StepBuilder("tableSwapStep", jobRepository) + .tasklet(tableSwapTasklet, transactionManager) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java new file mode 100644 index 000000000..c5afe7104 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/scheduler/RankingScheduler.java @@ -0,0 +1,59 @@ +package com.loopers.batch.job.ranking.scheduler; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RankingScheduler { + + private final JobLauncher jobLauncher; + + @Qualifier("weeklyRankingJob") + private final Job weeklyRankingJob; + + @Qualifier("monthlyRankingJob") + private final Job monthlyRankingJob; + + @Scheduled(cron = "0 0 2 * * MON") + public void runWeeklyRankingJob() { + String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + log.info(">>> Weekly Ranking Job Scheduler Start: {}", requestDate); + + try { + jobLauncher.run(weeklyRankingJob, new JobParametersBuilder() + .addString("requestDate", requestDate) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters()); + } catch (Exception e) { + log.error(">>> Weekly Ranking Job Error: {}", e.getMessage()); + } + } + + @Scheduled(cron = "0 0 3 1 * *") + public void runMonthlyRankingJob() { + String requestDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String startDate = LocalDateTime.now().minusMonths(1).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + + log.info(">>> Monthly Ranking Job Scheduler Start: {}", requestDate); + + try { + jobLauncher.run(monthlyRankingJob, new JobParametersBuilder() + .addString("requestDate", requestDate) + .addString("startDate", startDate) + .addLong("timestamp", System.currentTimeMillis()) + .toJobParameters()); + } catch (Exception e) { + log.error(">>> Monthly Ranking Job Error: {}", e.getMessage()); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java new file mode 100644 index 000000000..9fe8c066a --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingPrepareTasklet.java @@ -0,0 +1,24 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Component +@StepScope +@RequiredArgsConstructor +public class RankingPrepareTasklet implements Tasklet { + + private final WeeklyRankingWorkRepository workingRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + workingRepository.deleteAllInBatch(); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java new file mode 100644 index 000000000..4f0ebce0d --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/RankingTableSwapTasklet.java @@ -0,0 +1,40 @@ +package com.loopers.batch.job.ranking.step; + +import com.loopers.domain.rank.weekly.WeeklyRankingMV; +import com.loopers.domain.rank.weekly.WeeklyRankingMVRepository; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository; +import java.time.LocalDate; +import java.util.List; +import lombok.AllArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +@Component +@StepScope +@AllArgsConstructor +public class RankingTableSwapTasklet implements Tasklet { + + private final WeeklyRankingMVRepository mvRepository; + private final WeeklyRankingWorkRepository workRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String baseDate = LocalDate.now().toString(); + + mvRepository.deleteAllInBatch(); + + List workData = workRepository.findAll(); + + List newData = workData.stream() + .map(work -> WeeklyRankingMV.createFromWork(work, baseDate)) + .toList(); + + mvRepository.saveAll(newData); + return RepeatStatus.FINISHED; + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java new file mode 100644 index 000000000..1ac276b26 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/weekly/WeeklyRankingProcessor.java @@ -0,0 +1,30 @@ +package com.loopers.batch.job.ranking.step.weekly; + +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.stereotype.Component; + +@Component +@StepScope +public class WeeklyRankingProcessor implements ItemProcessor { + + private int rankCounter = 0; + + @Override + public WeeklyRankingWork process(ProductMetrics item) { + rankCounter++; + if (rankCounter > 100) { + return null; + } + + Double score = (item.getViewCount() * 0.1) + (item.getLikeCount() * 0.2) + (item.getSalesCount() * 0.6); + + return new WeeklyRankingWork( + item.getProductId(), + score, + rankCounter + ); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java new file mode 100644 index 000000000..7f9ce3b9b --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/ProductSnapshot.java @@ -0,0 +1,19 @@ +package com.loopers.domain.rank.monthly; + +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ProductSnapshot implements Serializable { + private String name; + private long price; + private boolean isSoldOut; + + public static ProductSnapshot of(String name, long price, boolean isSoldOut) { + return new ProductSnapshot(name, price, isSoldOut); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java new file mode 100644 index 000000000..4c8dabf03 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMV.java @@ -0,0 +1,53 @@ +package com.loopers.domain.rank.weekly; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mv_product_rank_weekly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class WeeklyRankingMV { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String baseDate; + private Long productId; + private Double totalScore; + private Integer currentRank; + + private String productName; + private Long price; + private boolean isSoldOut; + + private WeeklyRankingMV(String baseDate, Long productId, Double totalScore, Integer currentRank, + String productName, Long price, boolean isSoldOut) { + this.baseDate = baseDate; + this.productId = productId; + this.totalScore = totalScore; + this.currentRank = currentRank; + this.productName = productName; + this.price = price; + this.isSoldOut = isSoldOut; + } + + // ์ •์  ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ (์˜๋ฏธ ์žˆ๋Š” ์ƒ์„ฑ ๋ฐฉ์‹ ์ œ๊ณต) + public static WeeklyRankingMV createFromWork(WeeklyRankingWork work, String baseDate) { + return new WeeklyRankingMV( + baseDate, + work.getProductId(), + work.getScore(), + work.getRanking(), + "์ƒํ’ˆ๋ช… ์ž„์‹œ", // ์‹ค์ œ ๊ตฌํ˜„ ์‹œ Product ์ •๋ณด ๊ฒฐํ•ฉ ํ•„์š” + 0L, // ์‹ค์ œ ๊ตฌํ˜„ ์‹œ Product ์ •๋ณด ๊ฒฐํ•ฉ ํ•„์š” + false // ์‹ค์ œ ๊ตฌํ˜„ ์‹œ Product ์ •๋ณด ๊ฒฐํ•ฉ ํ•„์š” + ); + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java new file mode 100644 index 000000000..2cb21d63c --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingMVRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.rank.weekly; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WeeklyRankingMVRepository extends JpaRepository { + + List findByBaseDateOrderByCurrentRankAsc(String baseDate, Pageable pageable); +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java new file mode 100644 index 000000000..afef5c3f0 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWork.java @@ -0,0 +1,30 @@ +package com.loopers.domain.rank.weekly; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "weekly_ranking_work") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class WeeklyRankingWork { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Long productId; + private Double score; + private Integer ranking; + + public WeeklyRankingWork(Long productId, Double score, Integer ranking) { + this.productId = productId; + this.score = score; + this.ranking = ranking; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java new file mode 100644 index 000000000..b1eb439fd --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/weekly/WeeklyRankingWorkRepository.java @@ -0,0 +1,9 @@ +package com.loopers.domain.rank.weekly; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WeeklyRankingWorkRepository extends JpaRepository { + +} From 5bd085e0255644284ba532ddcd09a693b2964b60 Mon Sep 17 00:00:00 2001 From: yeonjiyeon Date: Fri, 2 Jan 2026 15:44:29 +0900 Subject: [PATCH 164/164] =?UTF-8?q?feat:=20=EC=9B=94=EA=B0=84=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=20=EB=B0=B0=EC=B9=98=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=B0=8F=20Tasklet=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ์›”๊ฐ„ ๋žญํ‚น ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ ๋„๋ฉ”์ธ ์—”ํ‹ฐํ‹ฐ(MonthlyRankingMV) ๋ฐ ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ถ”๊ฐ€ - MonthlyRankingJobConfig์™€ Tasklet์œผ๋กœ ๋ฐฐ์น˜ ์žก ๊ตฌ์„ฑ - ์›”๊ฐ„ ์ ์ˆ˜ ๊ณ„์‚ฐ ๋ฐ ๋žญํ‚น ๋ฐ์ดํ„ฐ ์ค€๋น„๋ฅผ ์œ„ํ•œ Processing ๋กœ์ง ๊ตฌํ˜„ - Redis๋ฅผ ํ™œ์šฉํ•œ ์Šค๋ƒ…์ƒท ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๋ฐ ๋žญํ‚น ๋ฐ์ดํ„ฐ ์Šค์™‘ ๋กœ์ง ์ถ”๊ฐ€ --- .../job/ranking/MonthlyRankingJobConfig.java | 70 +++++++++++++++++++ .../MonthlyRankingTableSwapTasklet.java | 63 +++++++++++++++++ .../domain/rank/monthly/MonthlyRankingMV.java | 44 ++++++++++++ .../monthly/MonthlyRankingMVRepository.java | 12 ++++ 4 files changed, 189 insertions(+) create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java create mode 100644 apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java create mode 100644 modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java new file mode 100644 index 000000000..e7f2e6bf8 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/MonthlyRankingJobConfig.java @@ -0,0 +1,70 @@ +package com.loopers.batch.job.ranking; + +import com.loopers.batch.job.ranking.step.RankingPrepareTasklet; +import com.loopers.batch.job.ranking.step.monthly.MonthlyRankingTableSwapTasklet; +import com.loopers.domain.ProductMetrics; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.database.JpaItemWriter; +import org.springframework.batch.item.database.JpaPagingItemReader; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = MonthlyRankingJobConfig.JOB_NAME) +@Configuration +@RequiredArgsConstructor +public class MonthlyRankingJobConfig { + public static final String JOB_NAME = "monthlyRankingJob"; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final RankingPrepareTasklet prepareTasklet; + private final MonthlyRankingTableSwapTasklet tableSwapTasklet; // ์›”๊ฐ„ ์ „์šฉ ์Šค์™‘ + + private final JpaPagingItemReader monthlyRankingReader; + private final ItemProcessor rankingProcessor; + private final JpaItemWriter rankingWriter; + + @Bean(JOB_NAME) + public Job monthlyRankingJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(monthlyPrepareStep()) + .next(monthlyCalculationStep()) + .next(monthlyTableSwapStep()) + .build(); + } + + @Bean + public Step monthlyPrepareStep() { + return new StepBuilder("monthlyPrepareStep", jobRepository) + .tasklet(prepareTasklet, transactionManager) + .build(); + } + + @Bean + public Step monthlyCalculationStep() { + return new StepBuilder("monthlyCalculationStep", jobRepository) + .chunk(100, transactionManager) + .reader(monthlyRankingReader) // ๊ธฐ๊ฐ„์„ 30์ผ๋กœ ์„ค์ •ํ•œ Reader + .processor(rankingProcessor) + .writer(rankingWriter) + .build(); + } + + @Bean + public Step monthlyTableSwapStep() { + return new StepBuilder("monthlyTableSwapStep", jobRepository) + .tasklet(tableSwapTasklet, transactionManager) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java new file mode 100644 index 000000000..6b95e15b2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/ranking/step/monthly/MonthlyRankingTableSwapTasklet.java @@ -0,0 +1,63 @@ +package com.loopers.batch.job.ranking.step.monthly; + +import com.loopers.domain.rank.monthly.MonthlyRankingMV; +import com.loopers.domain.rank.monthly.MonthlyRankingMVRepository; +import com.loopers.domain.rank.monthly.ProductSnapshot; +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import com.loopers.domain.rank.weekly.WeeklyRankingWorkRepository; +import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MonthlyRankingTableSwapTasklet implements Tasklet { + + private final MonthlyRankingMVRepository monthlyMvRepository; + private final WeeklyRankingWorkRepository workRepository; + private final RedisTemplate redisTemplate; // Redis ์‚ฌ์šฉ + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + monthlyMvRepository.deleteAllInBatch(); + List workData = workRepository.findAll(); + if (workData.isEmpty()) return RepeatStatus.FINISHED; + + List keys = workData.stream() + .map(work -> "product:snapshot:" + work.getProductId()) + .toList(); + + List snapshots = redisTemplate.opsForValue().multiGet(keys); + + String baseDate = (String) chunkContext.getStepContext().getJobParameters().get("requestDate"); + if (baseDate == null) baseDate = "2026-01"; + + String finalBaseDate = baseDate; + List newData = IntStream.range(0, workData.size()) + .mapToObj(i -> { + WeeklyRankingWork work = workData.get(i); + ProductSnapshot snapshot = (ProductSnapshot) snapshots.get(i); // Redis์—์„œ ๊ฐ€์ ธ์˜จ ์Šค๋ƒ…์ƒท + + if (snapshot == null) { + return MonthlyRankingMV.createFromWork(work, finalBaseDate, "Unknown", 0L, true); + } + + return MonthlyRankingMV.createFromWork( + work, + "2026-01", + snapshot.getName(), + snapshot.getPrice(), + snapshot.isSoldOut() + ); + }).toList(); + + monthlyMvRepository.saveAll(newData); + return RepeatStatus.FINISHED; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java new file mode 100644 index 000000000..6bf2e1f01 --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMV.java @@ -0,0 +1,44 @@ +package com.loopers.domain.rank.monthly; + +import com.loopers.domain.rank.weekly.WeeklyRankingWork; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "mv_product_rank_monthly") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyRankingMV { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String baseDate; + private Long productId; + private Double totalScore; + private Integer currentRank; + + private String productName; + private Long price; + private boolean isSoldOut; + + public static MonthlyRankingMV createFromWork(WeeklyRankingWork work, String baseDate, String productName, Long price, boolean isSoldOut) { + MonthlyRankingMV mv = new MonthlyRankingMV(); + mv.baseDate = baseDate; + mv.productId = work.getProductId(); + mv.totalScore = work.getScore(); + mv.currentRank = work.getRanking(); + + mv.productName = productName; + mv.price = price; + mv.isSoldOut = isSoldOut; + + return mv; + } +} diff --git a/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java new file mode 100644 index 000000000..78214afcd --- /dev/null +++ b/modules/jpa/src/main/java/com/loopers/domain/rank/monthly/MonthlyRankingMVRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.rank.monthly; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface MonthlyRankingMVRepository extends JpaRepository { + + List findByBaseDateOrderByCurrentRankAsc(String baseDate, Pageable pageable); +}