diff --git a/.codeguide/loopers-1-week.md b/.codeguide/loopers-1-week.md deleted file mode 100644 index a8ace53e5..000000000 --- a/.codeguide/loopers-1-week.md +++ /dev/null @@ -1,45 +0,0 @@ -## ๐Ÿงช 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` ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..daad56c7c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,187 @@ +# CLAUDE.md + +AI ์–ด์‹œ์Šคํ„ดํŠธ๊ฐ€ ๋ณธ ํ”„๋กœ์ ํŠธ์˜ ์ฝ”๋”ฉ ๊ทœ์น™, ์•„ํ‚คํ…์ฒ˜, ๋„๋ฉ”์ธ ์„ค๊ณ„ ์ „๋žต์„ ์ค€์ˆ˜ํ•˜๋„๋ก ์•ˆ๋‚ดํ•˜๋Š” ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค. + +--- + +## ๊ฐ์ฒด์ง€ํ–ฅ & ๋„๋ฉ”์ธ ๋ชจ๋ธ๋ง ๊ทœ์น™ + +### ํ•ต์‹ฌ ์›์น™ + +- **๋„๋ฉ”์ธ ๊ฐ์ฒด๋Š” ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ์บก์Аํ™”**ํ•œ๋‹ค. ๋ฐ์ดํ„ฐ ์ €์žฅ์†Œ๊ฐ€ ์•„๋‹Œ, ๊ทœ์น™๊ณผ ํ–‰์œ„๋ฅผ ๊ฐ€์ง„ ๊ฐ์ฒด๋กœ ์„ค๊ณ„ํ•œ๋‹ค. +- **์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋น„์Šค**๋Š” ์„œ๋กœ ๋‹ค๋ฅธ ๋„๋ฉ”์ธ์„ ์กฐ๋ฆฝํ•˜๊ณ , ๋„๋ฉ”์ธ ๋กœ์ง์„ ์กฐ์ •ํ•˜์—ฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•œ๋‹ค. ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ์ง์ ‘ ๊ตฌํ˜„ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- **๊ทœ์น™์ด ์—ฌ๋Ÿฌ ์„œ๋น„์Šค์— ๋ฐ˜๋ณต๋˜๋ฉด** ๋„๋ฉ”์ธ ๊ฐ์ฒด(Entity, VO, Domain Service)๋กœ ์˜ฎ๊ธธ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’๋‹ค. +- ๊ฐ ๊ธฐ๋Šฅ์˜ **์ฑ…์ž„๊ณผ ๊ฒฐํ•ฉ๋„**๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜๊ณ , ๊ฐœ๋ฐœ ์˜๋„์— ๋งž๊ฒŒ ์„ค๊ณ„ํ•œ๋‹ค. + +### Entity ์„ค๊ณ„ + +- **๊ณ ์œ  ์‹๋ณ„์ž(ID)**๋ฅผ ๊ฐ€์ง€๋ฉฐ, ์ž์‹ ์˜ ์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” **ํ–‰์œ„ ๋ฉ”์„œ๋“œ**๋ฅผ ์ œ๊ณตํ•œ๋‹ค. +- `changePassword()`, `cancelOrder()`, `decreaseStock()`์ฒ˜๋Ÿผ **์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š” ๋ฉ”์„œ๋“œ๋ช…**์„ ์‚ฌ์šฉํ•œ๋‹ค. +- **๋ฌด๋ถ„๋ณ„ํ•œ Setter ์‚ฌ์šฉ์„ ๊ธˆ์ง€**ํ•œ๋‹ค. ์ƒํƒœ ๋ณ€๊ฒฝ์€ ๋ฐ˜๋“œ์‹œ ๋„๋ฉ”์ธ ๋ฉ”์„œ๋“œ๋ฅผ ํ†ตํ•ด ์ด๋ฃจ์–ด์ง„๋‹ค. +- ์ƒ์„ฑ์ž์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋ฉ”์„œ๋“œ ๋‚ด๋ถ€์—์„œ **ํ•„์ˆ˜ ๊ฒ€์ฆ**์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. + +### Value Object (VO) ์„ค๊ณ„ + +- **์‹๋ณ„์ž๊ฐ€ ์—†๋Š”** ๊ฐ’ ๊ฐ์ฒด์ด๋‹ค. +- **๋ถˆ๋ณ€(Immutable)**์œผ๋กœ ์„ค๊ณ„ํ•˜๊ณ , ์ƒ์„ฑ ์‹œ์ ์— **์ž์ฒด ๊ฒ€์ฆ ๋กœ์ง**์„ ํฌํ•จํ•œ๋‹ค. +- `Money`, `Quantity`, `LoginId`, `Email`์ฒ˜๋Ÿผ ์˜๋ฏธ ์žˆ๋Š” ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. +- ๋™์ผ์„ฑ์€ **๊ฐ’์˜ ๋™๋“ฑ์„ฑ**์œผ๋กœ ํŒ๋‹จํ•œ๋‹ค. + +### Domain Service ์„ค๊ณ„ + +- **ํŠน์ • ์—”ํ‹ฐํ‹ฐ์— ๋‘๊ธฐ ์–ด๋ ค์šด** ์—ฌ๋Ÿฌ ์—”ํ‹ฐํ‹ฐ ๊ฐ„ ์กฐ์œจ์ด๋‚˜ ๋ณต์žกํ•œ ๋„๋ฉ”์ธ ์ •์ฑ…์„ ์ฒ˜๋ฆฌํ•œ๋‹ค. +- **๋ฌด์ƒํƒœ(Stateless)**๋กœ ์„ค๊ณ„ํ•œ๋‹ค. +- **๋™์ผํ•œ ๋„๋ฉ”์ธ ๊ฒฝ๊ณ„ ๋‚ด**์˜ ๋„๋ฉ”์ธ ๊ฐ์ฒด ํ˜‘๋ ฅ์— ์ง‘์ค‘ํ•œ๋‹ค. +- ๋„๋ฉ”์ธ ๋‚ด๋ถ€ ๊ทœ์น™์€ Domain Service์— ๋‘๊ณ , Application Layer๋Š” ์กฐํ•ฉ๋งŒ ๋‹ด๋‹นํ•œ๋‹ค. + +### ๋นˆ์•ฝํ•œ ๋„๋ฉ”์ธ ๋ชจ๋ธ ์ง€์–‘ + +- ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ Application Service์— ๋‘์ง€ ๋ง๊ณ , **Entity์™€ VO ๋‚ด๋ถ€์— ์‘์ง‘**์‹œํ‚จ๋‹ค. +- Getter/Setter๋งŒ ์žˆ๋Š” ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค๋Š” ์ง€์–‘ํ•˜๊ณ , **ํ–‰์œ„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š” ๋ฉ”์„œ๋“œ**๋ฅผ ์šฐ์„ ํ•œ๋‹ค. + +--- + +## ์•„ํ‚คํ…์ฒ˜ ์ „๋žต & ํŒจํ‚ค์ง€ ๊ตฌ์„ฑ + +### ๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜ + DIP + +- ๋ณธ ํ”„๋กœ์ ํŠธ๋Š” **๋ ˆ์ด์–ด๋“œ ์•„ํ‚คํ…์ฒ˜**๋ฅผ ๋”ฐ๋ฅด๋ฉฐ, **DIP(์˜์กด์„ฑ ์—ญ์ „ ์›์น™)**๋ฅผ ์ค€์ˆ˜ํ•œ๋‹ค. +- ์˜์กด์„ฑ ๋ฐฉํ–ฅ: **Infrastructure โ†’ Domain โ† Application** +- **Domain Layer**๋Š” ์™ธ๋ถ€ ๊ธฐ์ˆ (JPA, Spring ๋“ฑ)์— ์˜์กดํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### ๊ณ„์ธต ๊ตฌ์กฐ + +``` +Application โ”€โ”€โ†’ Domain โ†โ”€โ”€ Infrastructure + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ””โ”€โ”€ Repository ๊ตฌํ˜„์ฒด, JPA Entity, Mapper + โ”‚ โ””โ”€โ”€ Entity, VO, Domain Service, Repository Interface + โ””โ”€โ”€ Service, Facade (๋„๋ฉ”์ธ ์กฐํ•ฉ, ํ๋ฆ„ ์ œ์–ด) +``` + +- **Interfaces (Presentation)**: HTTP ์š”์ฒญ/์‘๋‹ต, Request/Response DTO, ์ž…๋ ฅ ๊ฒ€์ฆ. ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์—†์Œ. +- **Application**: ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, ๋„๋ฉ”์ธ ์กฐํ•ฉ, ํ๋ฆ„ ์ œ์–ด. ๋กœ์ง์€ ๋„๋ฉ”์ธ์— ์œ„์ž„. +- **Domain**: ์ˆœ์ˆ˜ ๋„๋ฉ”์ธ ๊ฐ์ฒด, Repository Interface. ์™ธ๋ถ€ ์˜์กด์„ฑ 0%. +- **Infrastructure**: Repository ๊ตฌํ˜„์ฒด, JPA Entity, DB/Redis ๋“ฑ ๊ธฐ์ˆ  ๊ตฌํ˜„. + +### DTO ๋ถ„๋ฆฌ + +- **API Request/Response DTO**์™€ **Application Layer DTO**๋Š” ๋ถ„๋ฆฌํ•ด ์ž‘์„ฑํ•œ๋‹ค. +- API DTO๋Š” `interfaces.api.*`์—, Application DTO๋Š” `application.*`์— ์œ„์น˜ํ•œ๋‹ค. + +### ํŒจํ‚ค์ง€ ๊ตฌ์„ฑ + +4๊ฐœ ๋ ˆ์ด์–ด ํŒจํ‚ค์ง€๋ฅผ ๋‘๊ณ , ํ•˜์œ„์— **๋„๋ฉ”์ธ๋ณ„**๋กœ ํŒจํ‚ค์ง•ํ•œ๋‹ค. + +``` +/interfaces/api/{domain} # Presentation - API Controller, API DTO +/application/{domain} # Application - Service, Facade, Application DTO +/domain/{domain} # Domain - Entity, VO, Domain Service, Repository Interface +/infrastructure/{domain} # Infrastructure - Repository ๊ตฌํ˜„์ฒด, JPA Entity, Mapper +``` + +**์˜ˆ์‹œ** + +``` +com.loopers +โ”œโ”€โ”€ interfaces +โ”‚ โ””โ”€โ”€ api +โ”‚ โ”œโ”€โ”€ product +โ”‚ โ”œโ”€โ”€ order +โ”‚ โ””โ”€โ”€ like +โ”œโ”€โ”€ application +โ”‚ โ”œโ”€โ”€ product +โ”‚ โ”‚ โ”œโ”€โ”€ ProductFacade +โ”‚ โ”‚ โ””โ”€โ”€ ProductInfo +โ”‚ โ””โ”€โ”€ order +โ”‚ โ””โ”€โ”€ OrderService +โ”œโ”€โ”€ domain +โ”‚ โ”œโ”€โ”€ product +โ”‚ โ”‚ โ”œโ”€โ”€ Product +โ”‚ โ”‚ โ”œโ”€โ”€ Brand +โ”‚ โ”‚ โ”œโ”€โ”€ Stock +โ”‚ โ”‚ โ””โ”€โ”€ ProductRepository +โ”‚ โ”œโ”€โ”€ like +โ”‚ โ”‚ โ”œโ”€โ”€ Like +โ”‚ โ”‚ โ””โ”€โ”€ LikeRepository +โ”‚ โ””โ”€โ”€ order +โ”‚ โ”œโ”€โ”€ Order +โ”‚ โ”œโ”€โ”€ OrderLine +โ”‚ โ””โ”€โ”€ OrderRepository +โ””โ”€โ”€ infrastructure + โ”œโ”€โ”€ product + โ”‚ โ”œโ”€โ”€ ProductRepositoryImpl + โ”‚ โ””โ”€โ”€ ProductJpaRepository + โ””โ”€โ”€ order + โ””โ”€โ”€ OrderRepositoryImpl +``` + +### Service vs Facade + +- **Service**: ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, **์ƒํƒœ ๋ณ€๊ฒฝ**์ด ์žˆ๋Š” ๋ณต์žกํ•œ ๋น„์ฆˆ๋‹ˆ์Šค ํ๋ฆ„. ๋„๋ฉ”์ธ ๊ฐ์ฒด์— ์œ„์ž„. +- **Facade**: **์ƒํƒœ ๋ณ€๊ฒฝ ์—†์ด** ์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒยท์กฐํ•ฉ(Aggregation)ํ•˜์—ฌ ๋ฐ˜ํ™˜. + +--- + +## ๋„๋ฉ”์ธ ์„ค๊ณ„ ๊ฐ€์ด๋“œ (Product, Brand, Like, Order) + +### Product / Brand + +- ์ƒํ’ˆ ์ •๋ณด๋Š” **๋ธŒ๋žœ๋“œ ์ •๋ณด**, **์ข‹์•„์š” ์ˆ˜**๋ฅผ ํฌํ•จํ•œ๋‹ค. +- ์ƒํ’ˆ ์ •๋ ฌ ์กฐ๊ฑด(`latest`, `price_asc`, `likes_desc`)์€ **์กฐํšŒ ์‹œ์ **์— ์ ์šฉํ•œ๋‹ค. +- ์ƒํ’ˆ์€ **์žฌ๊ณ (Stock)**๋ฅผ ๊ฐ€์ง€๋ฉฐ, ์ฃผ๋ฌธ ์‹œ **๋„๋ฉ”์ธ ๋ ˆ๋ฒจ**์—์„œ ์ฐจ๊ฐํ•œ๋‹ค. +- **์žฌ๊ณ  ์Œ์ˆ˜ ๋ฐฉ์ง€**๋Š” Entity ๋˜๋Š” Domain Service์—์„œ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +### Like + +- ์ข‹์•„์š”๋Š” **์œ ์ €์™€ ์ƒํ’ˆ ๊ฐ„ ๊ด€๊ณ„**๋กœ ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค. +- ์ƒํ’ˆ์˜ ์ข‹์•„์š” ์ˆ˜๋Š” **์กฐํšŒ ์‹œ์ ์— ์ง‘๊ณ„**ํ•˜์—ฌ ์ƒํ’ˆ ์ƒ์„ธ/๋ชฉ๋ก์— ํ•จ๊ป˜ ์ œ๊ณตํ•œ๋‹ค. +- ์ƒํ’ˆ์ด ์ข‹์•„์š” ์ˆ˜๋ฅผ **์ง์ ‘ ๊ด€๋ฆฌํ•˜์ง€ ์•Š๋Š”๋‹ค**. Like ๋„๋ฉ”์ธ์ด ์ง‘๊ณ„๋ฅผ ๋‹ด๋‹นํ•œ๋‹ค. + +### Order + +- ์ฃผ๋ฌธ์€ **์—ฌ๋Ÿฌ ์ƒํ’ˆ**์„ ํฌํ•จํ•˜๋ฉฐ, ๊ฐ ์ƒํ’ˆ์˜ **์ˆ˜๋Ÿ‰**์„ ๋ช…์‹œํ•œ๋‹ค. +- ์ฃผ๋ฌธ ์‹œ **์ƒํ’ˆ ์žฌ๊ณ  ์ฐจ๊ฐ**์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌํ•œ๋‹ค. +- Order, Product, Stock ๊ฐ„ ํ˜‘๋ ฅ์€ **Domain Service**์—์„œ ์กฐ์œจํ•œ๋‹ค. + +--- + +## Feature Suggestions (์„ค๊ณ„ ์˜์‚ฌ๊ฒฐ์ • ๊ฐ€์ด๋“œ) + +### Q1. ์ƒํ’ˆ์ด ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ง์ ‘ ๊ด€๋ฆฌํ•ด์•ผ ํ• ๊นŒ? + +**์•„๋‹ˆ์˜ค.** ์ข‹์•„์š” ์ˆ˜๋Š” Like ๋„๋ฉ”์ธ์—์„œ ์ง‘๊ณ„ํ•œ๋‹ค. Product๋Š” `likeCount`๋ฅผ ํ•„๋“œ๋กœ ๊ฐ€์ง€์ง€ ์•Š๊ณ , ์กฐํšŒ ์‹œ์ ์— Application Layer ๋˜๋Š” Facade์—์„œ Product + Like๋ฅผ ์กฐํ•ฉํ•ด ์ œ๊ณตํ•œ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ ์‹œ Product๋ฅผ ์ˆ˜์ •ํ•  ํ•„์š”๊ฐ€ ์—†๊ณ , Like ๋„๋ฉ”์ธ์ด ๋‹จ์ผ ์ฑ…์ž„์„ ๊ฐ€์ง„๋‹ค. + +### Q2. ์ƒํ’ˆ ์ƒ์„ธ์—์„œ ๋ธŒ๋žœ๋“œ๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•˜๋ ค๋ฉด ๋ˆ„๊ฐ€ ์กฐํ•ฉํ•ด์•ผ ํ• ๊นŒ? + +**Application Layer (ProductFacade)**๊ฐ€ ์กฐํ•ฉํ•œ๋‹ค. `ProductFacade.getProductDetail(productId)`์—์„œ Product, Brand, Like๋ฅผ ์กฐํšŒํ•ด ํ•˜๋‚˜์˜ DTO๋กœ ์กฐํ•ฉํ•œ๋‹ค. Domain Layer๋Š” ๊ฐ์ž ์ž์‹ ์˜ ์ฑ…์ž„๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ , ์กฐํ•ฉ์€ Application์˜ ์—ญํ• ์ด๋‹ค. + +### Q3. VO๋ฅผ ๋„์ž…ํ•œ ์ด์œ ๋Š” ๋ฌด์—‡์ด๋ฉฐ, ์–ด๋А ์‹œ์ ์—์„œ ์œ ๋ฆฌํ•˜๊ฒŒ ์ž‘์šฉํ–ˆ๋Š”๊ฐ€? + +- **๊ฒ€์ฆ ๋กœ์ง ์‘์ง‘**: `Money`, `Quantity`์ฒ˜๋Ÿผ ์ƒ์„ฑ ์‹œ์ ์— ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ์บก์Аํ™”ํ•œ๋‹ค. +- **๋ถˆ๋ณ€์„ฑ ๋ณด์žฅ**: ๊ฐ’์ด ๋ณ€๊ฒฝ๋˜์ง€ ์•Š์•„ ๋ถ€์ž‘์šฉ์„ ์ค„์ธ๋‹ค. +- **์˜๋ฏธ ํ‘œํ˜„**: `Price price`๊ฐ€ `long price`๋ณด๋‹ค ์˜๋„๋ฅผ ์ž˜ ๋“œ๋Ÿฌ๋‚ธ๋‹ค. +- **์žฌ์‚ฌ์šฉ**: ์—ฌ๋Ÿฌ Entity์—์„œ ๋™์ผํ•œ VO๋ฅผ ์‚ฌ์šฉํ•ด ์ผ๊ด€๋œ ๊ทœ์น™์„ ์ ์šฉํ•œ๋‹ค. + +### Q4. Order, Product, User ์ค‘ ๋ˆ„๊ฐ€ ์–ด๋–ค ์ฑ…์ž„์„ ๊ฐ–๋Š” ๊ฒƒ์ด ์ž์—ฐ์Šค๋Ÿฌ์› ๋‚˜? + +- **Order**: ์ฃผ๋ฌธ ์ƒ์„ฑ, ์ฃผ๋ฌธ ๋ผ์ธ ๊ด€๋ฆฌ, ์ฃผ๋ฌธ ์ƒํƒœ ๋ณ€๊ฒฝ. "์ฃผ๋ฌธํ•œ๋‹ค"๋Š” Order์˜ ์ฑ…์ž„. +- **Product**: ์ƒํ’ˆ ์ •๋ณด, ์žฌ๊ณ  ์ฐจ๊ฐ(`decreaseStock()`). "์žฌ๊ณ ๋ฅผ ์ค„์ธ๋‹ค"๋Š” Product(๋˜๋Š” Stock)์˜ ์ฑ…์ž„. +- **User/Member**: ํšŒ์› ์ •๋ณด, ์ธ์ฆ. ์ฃผ๋ฌธ ์‹œ์—๋Š” ์‹๋ณ„์ž๋งŒ ์ฐธ์กฐํ•œ๋‹ค. +- **Domain Service**: Order์™€ Product ๊ฐ„ ์žฌ๊ณ  ์ฐจ๊ฐยท๊ฒ€์ฆ ๋“ฑ **์—ฌ๋Ÿฌ ์—”ํ‹ฐํ‹ฐ ํ˜‘๋ ฅ**์€ Domain Service๊ฐ€ ์กฐ์œจํ•œ๋‹ค. + +### Q5. Repository Interface๋ฅผ Domain Layer์— ๋‘๋Š” ์ด์œ ๋Š”? + +**DIP ์ ์šฉ**์„ ์œ„ํ•ด์„œ๋‹ค. Application/Domain์€ "์ €์žฅยท์กฐํšŒ" ์ธํ„ฐํŽ˜์ด์Šค๋งŒ ์•Œ๊ณ , ์‹ค์ œ ๊ตฌํ˜„(JDBC, JPA ๋“ฑ)์€ Infrastructure์— ๋‘”๋‹ค. Domain์ด Infrastructure์— ์˜์กดํ•˜์ง€ ์•Š๋„๋ก ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ Domain์— ๋‘๊ณ , ๊ตฌํ˜„์ฒด๊ฐ€ ์ด๋ฅผ ๋”ฐ๋ฅธ๋‹ค. + +### Q6. ์ฒ˜์Œ์—” ๋„๋ฉ”์ธ์— ๋‘๋ ค ํ–ˆ์ง€๋งŒ, ๊ฒฐ๊ตญ Application Layer๋กœ ์˜ฎ๊ธด ์ด์œ ๋Š”? + +- **ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„**: `@Transactional`์€ Application Layer์—์„œ ๊ด€๋ฆฌํ•˜๋Š” ๊ฒƒ์ด ์ž์—ฐ์Šค๋Ÿฝ๋‹ค. +- **์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ ์กฐํ•ฉ**: Product + Brand + Like ์กฐํ•ฉ์€ ๋‹จ์ผ ๋„๋ฉ”์ธ ์ฑ…์ž„์„ ๋„˜์–ด์„œ๋ฏ€๋กœ Application(Facade)์— ๋‘”๋‹ค. +- **์™ธ๋ถ€ ์˜์กด์„ฑ**: Domain์€ Spring, JPA ๋“ฑ์— ์˜์กดํ•˜์ง€ ์•Š์•„์•ผ ํ•˜๋ฏ€๋กœ, ํŠธ๋žœ์žญ์…˜ ์–ด๋…ธํ…Œ์ด์…˜์„ ์“ฐ๋Š” ํด๋ž˜์Šค๋Š” Application์— ๋‘”๋‹ค. + +### Q7. ํ…Œ์ŠคํŠธ ๊ฐ€๋Šฅํ•œ ๊ตฌ์กฐ๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด ๊ฐ€์žฅ ๋จผ์ € ๊ณ ๋ คํ•œ ๊ฑด ๋ฌด์—‡์ด์—ˆ๋‚˜? + +- **Repository Interface ๋ถ„๋ฆฌ**: Domain์— ์ธํ„ฐํŽ˜์ด์Šค๋ฅผ ๋‘๊ณ , ๋‹จ์œ„ ํ…Œ์ŠคํŠธ์—์„œ๋Š” **Fake/Stub ๊ตฌํ˜„์ฒด**๋ฅผ ์ฃผ์ž…ํ•œ๋‹ค. +- **๋„๋ฉ”์ธ ๋กœ์ง ์ˆœ์ˆ˜์„ฑ**: Entity, VO, Domain Service๊ฐ€ ์™ธ๋ถ€ ์˜์กด ์—†์ด ๋™์ž‘ํ•˜๋„๋ก ์„ค๊ณ„ํ•ด, **๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋งŒ์œผ๋กœ** ๋น„์ฆˆ๋‹ˆ์Šค ๊ทœ์น™์„ ๊ฒ€์ฆํ•œ๋‹ค. +- **์˜์กด์„ฑ ์ฃผ์ž…**: Service๊ฐ€ Repository ์ธํ„ฐํŽ˜์ด์Šค์— ์˜์กดํ•˜๋„๋ก ํ•ด, ํ…Œ์ŠคํŠธ ์‹œ Mock/Fake๋กœ ๋Œ€์ฒด ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•œ๋‹ค. diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 03ce68f02..cb54a44be 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -8,6 +8,7 @@ dependencies { // web implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.security:spring-security-crypto") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") diff --git a/apps/commerce-api/docs/README.md b/apps/commerce-api/docs/README.md new file mode 100644 index 000000000..04099d0bd --- /dev/null +++ b/apps/commerce-api/docs/README.md @@ -0,0 +1,28 @@ +# Commerce API ๋ฌธ์„œ + +Claude Code / AI ์–ด์‹œ์Šคํ„ดํŠธ ์ž‘์—… ์‹œ ์ฐธ๊ณ ํ•  ๋„๋ฉ”์ธ๋ณ„ ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค. + +## ๋ฌธ์„œ ์œ„์น˜ + +๊ฐ ๊ตฌํ˜„์ฒด(๋„๋ฉ”์ธ, Application Layer) ํด๋”์— README.md๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. + +| ์˜์—ญ | ๊ฒฝ๋กœ | +|------|------| +| Product/Brand ๋„๋ฉ”์ธ | `src/main/java/com/loopers/domain/product/README.md` | +| Like ๋„๋ฉ”์ธ | `src/main/java/com/loopers/domain/like/README.md` | +| Order ๋„๋ฉ”์ธ | `src/main/java/com/loopers/domain/order/README.md` | +| Product Application | `src/main/java/com/loopers/application/product/README.md` | +| Like Application | `src/main/java/com/loopers/application/like/README.md` | +| Order Application | `src/main/java/com/loopers/application/order/README.md` | + +## Cursor Rules + +`.cursor/rules/`์— ๋„๋ฉ”์ธ๋ณ„ ๊ทœ์น™์ด ๋“ฑ๋ก๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ํ•ด๋‹น ๊ฒฝ๋กœ์˜ ํŒŒ์ผ์„ ํŽธ์ง‘ํ•  ๋•Œ ์ž๋™์œผ๋กœ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. + +- `domain-product.mdc` โ€” Product, Brand ๊ด€๋ จ +- `domain-like.mdc` โ€” Like ๊ด€๋ จ +- `domain-order.mdc` โ€” Order ๊ด€๋ จ + +## ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ + +ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ `CLAUDE.md`๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”. diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java new file mode 100644 index 000000000..4bb889d4d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -0,0 +1,33 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.ProductRepository; +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 LikeService { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + @Transactional + public void like(Long memberId, Long productId) { + if (likeRepository.existsByMemberIdAndProductId(memberId, productId)) { + return; + } + productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + productId + "] ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + likeRepository.save(new Like(memberId, productId)); + } + + @Transactional + public void unlike(Long memberId, Long productId) { + likeRepository.deleteByMemberIdAndProductId(memberId, productId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/README.md b/apps/commerce-api/src/main/java/com/loopers/application/like/README.md new file mode 100644 index 000000000..e500a78d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/README.md @@ -0,0 +1,29 @@ +# Like Application Layer + +> Claude Code ์ž‘์—… ์‹œ ์ด ์˜์—ญ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **LikeService**: ์ข‹์•„์š” ๋“ฑ๋ก/์ทจ์†Œ. ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, ๋„๋ฉ”์ธ์— ์œ„์ž„. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Service = ํŠธ๋žœ์žญ์…˜ + ํ๋ฆ„ ์ œ์–ด** + ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ Like, Product ๋„๋ฉ”์ธ์— ์œ„์ž„. + +2. **๋ฉฑ๋“ฑ์„ฑ** + `like()` โ€” ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ๋ฌด์‹œ. `unlike()` โ€” ์—†์–ด๋„ ์˜ˆ์™ธ ์—†์Œ. + +3. **Product ์กด์žฌ ๊ฒ€์ฆ** + ์ข‹์•„์š” ๋“ฑ๋ก ์ „ `productRepository.findById()`๋กœ ์ƒํ’ˆ ์กด์žฌ ํ™•์ธ. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| LikeService | like(), unlike() | + +## ์ฐธ์กฐ + +- [domain/like README](../../domain/like/README.md) โ€” Like ๋„๋ฉ”์ธ ๊ทœ์น™ +- [CLAUDE.md](/CLAUDE.md) โ€” ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java new file mode 100644 index 000000000..df7e4327d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -0,0 +1,31 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderDomainService.OrderLineRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderDomainService orderDomainService; + + @Transactional + public OrderResult placeOrder(Long memberId, List items) { + Order order = orderDomainService.placeOrder(memberId, items); + List orderLines = order.getOrderLines().stream() + .map(ol -> new OrderLineInfo(ol.getProductId(), ol.getQuantity(), ol.getUnitPrice())) + .collect(Collectors.toList()); + return new OrderResult(order.getId(), order.getStatus(), order.getTotalAmount(), orderLines); + } + + public record OrderResult(Long orderId, String status, long totalAmount, List orderLines) {} + + public record OrderLineInfo(Long productId, int quantity, long unitPrice) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/README.md b/apps/commerce-api/src/main/java/com/loopers/application/order/README.md new file mode 100644 index 000000000..8149cb557 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/README.md @@ -0,0 +1,28 @@ +# Order Application Layer + +> Claude Code ์ž‘์—… ์‹œ ์ด ์˜์—ญ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **OrderService**: ์ฃผ๋ฌธ ์ƒ์„ฑ. ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ, OrderDomainService์— ์œ„์ž„. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Service = ํŠธ๋žœ์žญ์…˜ + ์œ„์ž„** + `placeOrder()` โ†’ `orderDomainService.placeOrder()` ํ˜ธ์ถœ. + ๋„๋ฉ”์ธ ๋กœ์ง์€ OrderDomainService, Product, Order์— ์œ„์ž„. + +2. **OrderResult ๋ณ€ํ™˜** + Order ์—”ํ‹ฐํ‹ฐ โ†’ OrderResult (orderId, status, totalAmount, orderLines) โ†’ API DTO. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| OrderService | placeOrder() | +| OrderResult, OrderLineInfo | Application DTO | + +## ์ฐธ์กฐ + +- [domain/order README](../../domain/order/README.md) โ€” Order ๋„๋ฉ”์ธ ๊ทœ์น™ +- [CLAUDE.md](/CLAUDE.md) โ€” ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ 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..b43abe96f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailInfo.java @@ -0,0 +1,27 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; + +public record ProductDetailInfo( + Long id, + String name, + Long price, + int stockQuantity, + BrandInfo brand, + long likeCount +) { + public record BrandInfo(Long id, String name) {} + + public static ProductDetailInfo of(Product product, Brand brand, long likeCount) { + BrandInfo brandInfo = brand != null ? new BrandInfo(brand.getId(), brand.getName()) : null; + return new ProductDetailInfo( + product.getId(), + product.getName(), + product.getPrice(), + product.getStockQuantity(), + brandInfo, + likeCount + ); + } +} 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..771d85bd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,59 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + private final LikeRepository likeRepository; + + public ProductDetailInfo getProductDetail(Long productId) { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + productId + "] ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + Brand brand = product.getBrandId() != null + ? brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "๋ธŒ๋žœ๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")) + : null; + long likeCount = likeRepository.countByProductId(productId); + return ProductDetailInfo.of(product, brand, likeCount); + } + + public List getProductList(SortCondition sort) { + List products = productRepository.findAll(sort); + if (products.isEmpty()) { + return List.of(); + } + + List brandIds = products.stream() + .map(Product::getBrandId) + .filter(id -> id != null) + .distinct() + .toList(); + Map brandMap = brandIds.stream() + .flatMap(id -> brandRepository.findById(id).stream()) + .collect(Collectors.toMap(Brand::getId, b -> b)); + + List productIds = products.stream().map(Product::getId).toList(); + Map likeCountMap = likeRepository.countByProductIds(productIds); + + return products.stream() + .map(p -> ProductListInfo.of(p, brandMap, likeCountMap)) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java new file mode 100644 index 000000000..0bc57d49a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductListInfo.java @@ -0,0 +1,31 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.Product; + +import java.util.Map; + +public record ProductListInfo( + Long id, + String name, + Long price, + String brandName, + long likeCount +) { + public static ProductListInfo of(Product product, Brand brand, long likeCount) { + String brandName = brand != null ? brand.getName() : ""; + return new ProductListInfo( + product.getId(), + product.getName(), + product.getPrice(), + brandName, + likeCount + ); + } + + public static ProductListInfo of(Product product, Map brandMap, Map likeCountMap) { + Brand brand = product.getBrandId() != null ? brandMap.get(product.getBrandId()) : null; + long likeCount = likeCountMap.getOrDefault(product.getId(), 0L); + return of(product, brand, likeCount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/README.md b/apps/commerce-api/src/main/java/com/loopers/application/product/README.md new file mode 100644 index 000000000..32709364b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/README.md @@ -0,0 +1,35 @@ +# Product Application Layer + +> Claude Code ์ž‘์—… ์‹œ ์ด ์˜์—ญ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **ProductFacade**: ์ƒํƒœ ๋ณ€๊ฒฝ ์—†์ด Product + Brand + Like๋ฅผ ์กฐํšŒยท์กฐํ•ฉํ•˜์—ฌ ๋ฐ˜ํ™˜. +- **ProductDetailInfo, ProductListInfo**: Application DTO (API DTO์™€ ๋ถ„๋ฆฌ). + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Facade = ์กฐํ•ฉ ์ „์šฉ** + ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์—†์Œ. Domain Repository์—์„œ ์กฐํšŒ ํ›„ DTO๋กœ ๋ณ€ํ™˜. + +2. **์ƒํ’ˆ ์ƒ์„ธ ์กฐํ•ฉ** + `getProductDetail(productId)` โ†’ Product + Brand + likeCount ์กฐํ•ฉ. + +3. **์ƒํ’ˆ ๋ชฉ๋ก ์กฐํ•ฉ** + `getProductList(sort)` โ†’ ProductRepository.findAll(sort) + Brand ๋งต + Like ์ง‘๊ณ„ ๋งต โ†’ ProductListInfo ๋ฆฌ์ŠคํŠธ. + +4. **์ •๋ ฌ** + `latest`, `price_asc`, `likes_desc` โ€” ProductRepository์— ์œ„์ž„. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| ProductFacade | getProductDetail, getProductList | +| ProductDetailInfo | ์ƒ์„ธ ์กฐํšŒ์šฉ DTO | +| ProductListInfo | ๋ชฉ๋ก ์กฐํšŒ์šฉ DTO | + +## ์ฐธ์กฐ + +- [domain/product README](../../domain/product/README.md) โ€” Product ๋„๋ฉ”์ธ ๊ทœ์น™ +- [CLAUDE.md](/CLAUDE.md) โ€” ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java new file mode 100644 index 000000000..d42feb176 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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..e62fd686b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,47 @@ +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; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table(name = "product_like", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "product_id"}) +}) +public class Like extends BaseEntity { + + private Long memberId; + private Long productId; + + protected Like() {} + + public Like(Long memberId, Long productId) { + validateMemberId(memberId); + validateProductId(productId); + this.memberId = memberId; + this.productId = productId; + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateProductId(Long productId) { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public Long getMemberId() { + return memberId; + } + + 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..6d4cacfd2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Map; + +public interface LikeRepository { + + Like save(Like like); + + void deleteByMemberIdAndProductId(Long memberId, Long productId); + + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + + long countByProductId(Long productId); + + Map countByProductIds(List productIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md new file mode 100644 index 000000000..9ddd1844d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/README.md @@ -0,0 +1,35 @@ +# Like ๋„๋ฉ”์ธ + +> Claude Code ์ž‘์—… ์‹œ ์ด ๋„๋ฉ”์ธ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **Like**: ํšŒ์›(Member)๊ณผ ์ƒํ’ˆ(Product) ๊ฐ„์˜ ์ข‹์•„์š” ๊ด€๊ณ„. +- ๋ณ„๋„ ๋„๋ฉ”์ธ์œผ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ Product๊ฐ€ ์ข‹์•„์š” ์ˆ˜๋ฅผ ์ง์ ‘ ๊ด€๋ฆฌํ•˜์ง€ ์•Š์Œ. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **(member_id, product_id) UNIQUE** + ํ•œ ํšŒ์›์ด ํ•œ ์ƒํ’ˆ์— ํ•œ ๋ฒˆ๋งŒ ์ข‹์•„์š” ๊ฐ€๋Šฅ. + +2. **์ข‹์•„์š” ์ˆ˜ ์ง‘๊ณ„** + `LikeRepository.countByProductId()`, `countByProductIds()` โ€” ์กฐํšŒ ์‹œ์ ์— ์ง‘๊ณ„. + +3. **Product์™€ ๋ถ„๋ฆฌ** + Product ์—”ํ‹ฐํ‹ฐ์— likeCount ํ•„๋“œ ์—†์Œ. Application Layer์—์„œ ์กฐํ•ฉ. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| Like | ์ข‹์•„์š” ์—”ํ‹ฐํ‹ฐ (memberId, productId) | +| LikeRepository | ์ €์žฅ, ์‚ญ์ œ, ์กด์žฌ ์—ฌ๋ถ€, ์ง‘๊ณ„ | + +## API ํ๋ฆ„ + +- **๋“ฑ๋ก**: `LikeService.like()` โ†’ ์ค‘๋ณต ์‹œ ๋ฉฑ๋“ฑ, Product ์กด์žฌ ๊ฒ€์ฆ ํ›„ ์ €์žฅ +- **์ทจ์†Œ**: `LikeService.unlike()` โ†’ `deleteByMemberIdAndProductId()` + +## ์ฐธ์กฐ + +- [CLAUDE.md](/CLAUDE.md) โ€” ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java new file mode 100644 index 000000000..7e6c17b88 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/Member.java @@ -0,0 +1,81 @@ +package com.loopers.domain.member; + +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 = "member") +public class Member extends BaseEntity { + + private String loginId; + private String encryptedPassword; + private String name; + private String birthDate; + private String email; + + protected Member() {} + + public Member(String loginId, String encryptedPassword, String name, + String birthDate, String email) { + validateLoginId(loginId); + validatePassword(encryptedPassword); + validateName(name); + validateBirthDate(birthDate); + validateEmail(email); + + this.loginId = loginId; + this.encryptedPassword = encryptedPassword; + this.name = name; + this.birthDate = birthDate; + this.email = email; + } + + private void validateLoginId(String loginId) { + if (loginId == null || loginId.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋กœ๊ทธ์ธID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validatePassword(String password) { + if (password == null || password.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validateBirthDate(String birthDate) { + if (birthDate == null || birthDate.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + private void validateEmail(String email) { + if (email == null || email.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"); + } + } + + public String getLoginId() { + return loginId; + } + + public String getName() { + return name; + } + + public String getBirthDate() { + return birthDate; + } + + public String getEmail() { + return email; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java new file mode 100644 index 000000000..f06cecd27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.member; + +import java.util.Optional; + +public interface MemberRepository { + + Member save(Member member); + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java new file mode 100644 index 000000000..cf76b7e5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/member/MemberService.java @@ -0,0 +1,27 @@ +package com.loopers.domain.member; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public Member signUp(String loginId, String rawPassword, String name, String birthDate, String email) { + if (memberRepository.existsByLoginId(loginId)) { + throw new CoreException(ErrorType.CONFLICT, "์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ๋กœ๊ทธ์ธID์ž…๋‹ˆ๋‹ค."); + } + + String encryptedPassword = passwordEncoder.encode(rawPassword); + Member member = new Member(loginId, encryptedPassword, name, birthDate, email); + return memberRepository.save(member); + } +} 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..fd7f2607d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,118 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + private static final String STATUS_ORDERED = "ORDERED"; + + private Long memberId; + private String status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List orderLines = new ArrayList<>(); + + protected Order() {} + + private Order(Long memberId, List orderLines) { + validateMemberId(memberId); + validateOrderLines(orderLines); + this.memberId = memberId; + this.status = STATUS_ORDERED; + for (OrderLine line : orderLines) { + this.orderLines.add(new OrderLineEntity(this, line.productId(), line.quantity(), line.unitPrice())); + } + } + + public static Order create(Long memberId, List orderLines) { + return new Order(memberId, orderLines); + } + + private void validateMemberId(Long memberId) { + if (memberId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateOrderLines(List orderLines) { + if (orderLines == null || orderLines.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + } + + public Long getMemberId() { + return memberId; + } + + public String getStatus() { + return status; + } + + public List getOrderLines() { + return Collections.unmodifiableList(orderLines); + } + + public long getTotalAmount() { + return orderLines.stream() + .mapToLong(OrderLineEntity::getTotalPrice) + .sum(); + } + + @jakarta.persistence.Entity + @jakarta.persistence.Table(name = "order_line") + public static class OrderLineEntity { + @jakarta.persistence.Id + @jakarta.persistence.GeneratedValue(strategy = jakarta.persistence.GenerationType.IDENTITY) + private Long id; + + @jakarta.persistence.ManyToOne(fetch = jakarta.persistence.FetchType.LAZY) + @jakarta.persistence.JoinColumn(name = "order_id", nullable = false) + private Order order; + + @jakarta.persistence.Column(name = "product_id", nullable = false) + private Long productId; + + @jakarta.persistence.Column(nullable = false) + private int quantity; + + @jakarta.persistence.Column(name = "unit_price", nullable = false) + private long unitPrice; + + protected OrderLineEntity() {} + + OrderLineEntity(Order order, Long productId, int quantity, long unitPrice) { + this.order = order; + this.productId = productId; + this.quantity = quantity; + this.unitPrice = unitPrice; + } + + public long getTotalPrice() { + return (long) quantity * unitPrice; + } + + public Long getProductId() { + return productId; + } + + public int getQuantity() { + return quantity; + } + + public long getUnitPrice() { + return unitPrice; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java new file mode 100644 index 000000000..47aa788db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -0,0 +1,33 @@ +package com.loopers.domain.order; + +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.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderDomainService { + + private final ProductRepository productRepository; + private final OrderRepository orderRepository; + + public Order placeOrder(Long memberId, List items) { + List orderLines = new ArrayList<>(); + for (OrderLineRequest item : items) { + Product product = productRepository.findById(item.productId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + item.productId() + "] ์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + product.decreaseStock(item.quantity()); + orderLines.add(new OrderLine(item.productId(), item.quantity(), product.getPrice())); + } + Order order = Order.create(memberId, orderLines); + return orderRepository.save(order); + } + + public record OrderLineRequest(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java new file mode 100644 index 000000000..622b7464e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLine.java @@ -0,0 +1,23 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record OrderLine(Long productId, int quantity, long unitPrice) { + + public OrderLine { + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (unitPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋‹จ๊ฐ€๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public long getTotalPrice() { + return (long) quantity * unitPrice; + } +} 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..cfd052c8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,10 @@ +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/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md new file mode 100644 index 000000000..04037e6b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/README.md @@ -0,0 +1,42 @@ +# Order ๋„๋ฉ”์ธ + +> Claude Code ์ž‘์—… ์‹œ ์ด ๋„๋ฉ”์ธ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **Order**: ์ฃผ๋ฌธ ์—”ํ‹ฐํ‹ฐ. ์—ฌ๋Ÿฌ OrderLine ํฌํ•จ. +- **OrderLine**: ์ฃผ๋ฌธ ํ•ญ๋ชฉ VO (productId, quantity, unitPrice ์Šค๋ƒ…์ƒท). +- **OrderDomainService**: Order์™€ Product ๊ฐ„ ์žฌ๊ณ  ์ฐจ๊ฐยท์ฃผ๋ฌธ ์ƒ์„ฑ ์กฐ์œจ. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ์ฐจ๊ฐ** + `OrderDomainService.placeOrder()`์—์„œ ๊ฐ Product์— `decreaseStock()` ํ˜ธ์ถœ. + ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ โ†’ Order ๋ฏธ์ƒ์„ฑ (ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ). + +2. **๊ฐ€๊ฒฉ ์Šค๋ƒ…์ƒท** + OrderLine์— ์ฃผ๋ฌธ ์‹œ์  `unitPrice` ์ €์žฅ. ์ดํ›„ Product ๊ฐ€๊ฒฉ ๋ณ€๊ฒฝ๊ณผ ๋ฌด๊ด€. + +3. **๋„๋ฉ”์ธ ์„œ๋น„์Šค** + Order, Product ๊ฐ„ ํ˜‘๋ ฅ์€ `OrderDomainService`์—์„œ ์ฒ˜๋ฆฌ. + Application Layer(OrderService)๋Š” ํŠธ๋žœ์žญ์…˜๋งŒ ๊ด€๋ฆฌ. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| Order | ์ฃผ๋ฌธ ์—”ํ‹ฐํ‹ฐ, `Order.create()` ์ •์  ํŒฉํ† ๋ฆฌ | +| OrderLine | ์ฃผ๋ฌธ ํ•ญ๋ชฉ VO (๋ถˆ๋ณ€) | +| OrderDomainService | placeOrder โ€” ์žฌ๊ณ  ์ฐจ๊ฐ + Order ์ƒ์„ฑ | +| OrderRepository | ์ฃผ๋ฌธ ์ €์žฅ/์กฐํšŒ ์ธํ„ฐํŽ˜์ด์Šค | + +## ์ฃผ๋ฌธ ํ๋ฆ„ + +1. Product ์กฐํšŒ ๋ฐ ์žฌ๊ณ  ๊ฒ€์ฆ +2. `product.decreaseStock(quantity)` โ€” ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ ์˜ˆ์™ธ +3. `Order.create(memberId, orderLines)` โ€” Order + OrderLine ์ƒ์„ฑ +4. `orderRepository.save(order)` + +## ์ฐธ์กฐ + +- [CLAUDE.md](/CLAUDE.md) โ€” ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java new file mode 100644 index 000000000..687015ea8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java @@ -0,0 +1,31 @@ +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 = "brand") +public class Brand extends BaseEntity { + + private String name; + + protected Brand() {} + + public Brand(String name) { + validateName(name); + this.name = name; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + public String getName() { + return name; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java new file mode 100644 index 000000000..3491f67d4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/BrandRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.product; + +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/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..a0c55b125 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,81 @@ +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 = "product") +public class Product extends BaseEntity { + + private Long brandId; + private String name; + private Long price; + private int stockQuantity; + + protected Product() {} + + public Product(Long brandId, String name, Long price, int stockQuantity) { + validateBrandId(brandId); + validateName(name); + validatePrice(price); + validateStockQuantity(stockQuantity); + + this.brandId = brandId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + } + + private void validateBrandId(Long brandId) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "๋ธŒ๋žœ๋“œ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ƒํ’ˆ๋ช…์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค."); + } + } + + private void validatePrice(Long price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "๊ฐ€๊ฒฉ์€ 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + private void validateStockQuantity(int stockQuantity) { + if (stockQuantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์žฌ๊ณ ๋Š” 0 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + } + + public void decreaseStock(int quantity) { + if (quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."); + } + if (stockQuantity < quantity) { + throw new CoreException(ErrorType.INSUFFICIENT_STOCK, "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); + } + this.stockQuantity -= quantity; + } + + public Long getBrandId() { + return brandId; + } + + public String getName() { + return name; + } + + public Long getPrice() { + return price; + } + + public int getStockQuantity() { + return stockQuantity; + } +} 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..2350033de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); + + List findAll(SortCondition sort); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md b/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md new file mode 100644 index 000000000..c6a0f561a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/README.md @@ -0,0 +1,37 @@ +# Product / Brand ๋„๋ฉ”์ธ + +> Claude Code ์ž‘์—… ์‹œ ์ด ๋„๋ฉ”์ธ์˜ ์„ค๊ณ„ ์˜๋„์™€ ๊ทœ์น™์„ ์ฐธ๊ณ ํ•˜์„ธ์š”. + +## ์ฑ…์ž„ + +- **Product**: ์ƒํ’ˆ ์ •๋ณด, ์žฌ๊ณ  ๊ด€๋ฆฌ. `decreaseStock()`์œผ๋กœ ์ฃผ๋ฌธ ์‹œ ์žฌ๊ณ  ์ฐจ๊ฐ. +- **Brand**: ๋ธŒ๋žœ๋“œ ์ •๋ณด. Product๊ฐ€ `brandId`๋กœ ์ฐธ์กฐ. + +## ์„ค๊ณ„ ๊ทœ์น™ + +1. **Product๋Š” likeCount๋ฅผ ์ง์ ‘ ๊ฐ€์ง€์ง€ ์•Š์Œ** + ์ข‹์•„์š” ์ˆ˜๋Š” Like ๋„๋ฉ”์ธ์—์„œ ์ง‘๊ณ„ํ•˜๋ฉฐ, ์กฐํšŒ ์‹œ์ ์— Application Layer์—์„œ ์กฐํ•ฉ. + +2. **์žฌ๊ณ  ์ฐจ๊ฐ์€ ๋„๋ฉ”์ธ ๋ ˆ๋ฒจ์—์„œ ์ฒ˜๋ฆฌ** + `Product.decreaseStock(quantity)` ๋‚ด๋ถ€์—์„œ ์Œ์ˆ˜ ๋ฐฉ์ง€. + ์žฌ๊ณ  ๋ถ€์กฑ ์‹œ `CoreException(ErrorType.INSUFFICIENT_STOCK)` ๋ฐœ์ƒ. + +3. **ํ–‰์œ„ ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ** + Setter ๊ธˆ์ง€. `decreaseStock()` ๋“ฑ ์˜๋„๊ฐ€ ๋“œ๋Ÿฌ๋‚˜๋Š” ๋ฉ”์„œ๋“œ ์‚ฌ์šฉ. + +4. **์ •๋ ฌ ์กฐ๊ฑด (SortCondition)** + `latest`, `price_asc`, `likes_desc` โ€” Infrastructure์—์„œ ๊ตฌํ˜„. + +## ์ฃผ์š” ํด๋ž˜์Šค + +| ํด๋ž˜์Šค | ์—ญํ•  | +|--------|------| +| Product | ์ƒํ’ˆ ์—”ํ‹ฐํ‹ฐ, `decreaseStock()` | +| Brand | ๋ธŒ๋žœ๋“œ ์—”ํ‹ฐํ‹ฐ | +| ProductRepository | ์ƒํ’ˆ ์ €์žฅ/์กฐํšŒ ์ธํ„ฐํŽ˜์ด์Šค | +| BrandRepository | ๋ธŒ๋žœ๋“œ ์ €์žฅ/์กฐํšŒ ์ธํ„ฐํŽ˜์ด์Šค | +| SortCondition | ์ •๋ ฌ ์กฐ๊ฑด enum | + +## ์ฐธ์กฐ + +- [CLAUDE.md](/CLAUDE.md) โ€” ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ์ „์ฒด ์•„ํ‚คํ…์ฒ˜ ๊ทœ์น™ diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java new file mode 100644 index 000000000..4d71bddb4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/SortCondition.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum SortCondition { + latest, + price_asc, + likes_desc +} 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..530964b3e --- /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 org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByMemberIdAndProductId(Long memberId, Long productId); + + long countByProductId(Long productId); + + @Modifying + @Query("DELETE FROM Like l WHERE l.memberId = :memberId AND l.productId = :productId") + void deleteByMemberIdAndProductId(@Param("memberId") Long memberId, @Param("productId") Long productId); + + @Query("SELECT l.productId, COUNT(l) FROM Like l WHERE l.productId IN :productIds GROUP BY l.productId") + List countByProductIdsGroupByProductId(@Param("productIds") List productIds); +} 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..64bdd7d19 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,54 @@ +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.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void deleteByMemberIdAndProductId(Long memberId, Long productId) { + likeJpaRepository.deleteByMemberIdAndProductId(memberId, productId); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return likeJpaRepository.existsByMemberIdAndProductId(memberId, productId); + } + + @Override + public long countByProductId(Long productId) { + return likeJpaRepository.countByProductId(productId); + } + + @Override + public Map countByProductIds(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + List results = likeJpaRepository.countByProductIdsGroupByProductId(productIds); + Map map = results.stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> ((Number) row[1]).longValue() + )); + for (Long productId : productIds) { + map.putIfAbsent(productId, 0L); + } + return map; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java new file mode 100644 index 000000000..beebc4eca --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberJpaRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java new file mode 100644 index 000000000..6116d454e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/member/MemberRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class MemberRepositoryImpl implements MemberRepository { + + private final MemberJpaRepository memberJpaRepository; + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } + + @Override + public Optional findByLoginId(String loginId) { + return memberJpaRepository.findByLoginId(loginId); + } + + @Override + public boolean existsByLoginId(String loginId) { + return memberJpaRepository.existsByLoginId(loginId); + } +} 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..f2ee62050 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +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..309265e06 --- /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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@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 orderJpaRepository.findById(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java new file mode 100644 index 000000000..d532f2d35 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.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/product/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java new file mode 100644 index 000000000..ff9fedafc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/BrandRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.product.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@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/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..4a831751a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface ProductJpaRepository extends JpaRepository { + + @Query("SELECT p FROM Product p LEFT JOIN com.loopers.domain.like.Like l ON l.productId = p.id " + + "WHERE p.deletedAt IS NULL GROUP BY p ORDER BY COUNT(l) DESC") + List findAllOrderByLikesDesc(); +} 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..f2c3a8e96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,37 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAll(SortCondition sort) { + return switch (sort) { + case latest -> productJpaRepository.findAll(Sort.by(Sort.Direction.DESC, "createdAt")); + case price_asc -> productJpaRepository.findAll(Sort.by(Sort.Direction.ASC, "price")); + case likes_desc -> productJpaRepository.findAllOrderByLikesDesc(); + }; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..b689957af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@Tag(name = "Like V1 API", description = "์ข‹์•„์š” API") +@RequestMapping("/api/v1/likes") +public interface LikeV1ApiSpec { + + @Operation(summary = "์ข‹์•„์š” ๋“ฑ๋ก", description = "์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse like(@Valid @RequestBody LikeV1Dto.LikeRequest request); + + @Operation(summary = "์ข‹์•„์š” ์ทจ์†Œ", description = "์ƒํ’ˆ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse unlike( + @RequestParam Long memberId, + @RequestParam Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..2bc2fbcad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/likes") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeService likeService; + + @PostMapping + @Override + public ApiResponse like(@Valid @RequestBody LikeV1Dto.LikeRequest request) { + likeService.like(request.memberId(), request.productId()); + return ApiResponse.success(); + } + + @DeleteMapping + @Override + public ApiResponse unlike( + @RequestParam Long memberId, + @RequestParam Long productId + ) { + likeService.unlike(memberId, productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..0b7432bd8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,14 @@ +package com.loopers.interfaces.api.like; + +import jakarta.validation.constraints.NotNull; + +public class LikeV1Dto { + + public record LikeRequest( + @NotNull(message = "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long memberId, + + @NotNull(message = "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long productId + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java new file mode 100644 index 000000000..c93074ef0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1ApiSpec.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Member V1 API", description = "ํšŒ์› API") +@RequestMapping("/api/v1/members") +public interface MemberV1ApiSpec { + + @Operation(summary = "ํšŒ์›๊ฐ€์ž…", description = "์ƒˆ ํšŒ์›์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java new file mode 100644 index 000000000..e71ec02d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import com.loopers.domain.member.MemberService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/members") +public class MemberV1Controller implements MemberV1ApiSpec { + + private final MemberService memberService; + + @PostMapping("/signup") + @Override + public ApiResponse signUp(@Valid @RequestBody MemberV1Dto.SignUpRequest request) { + Member member = memberService.signUp( + request.loginId(), + request.password(), + request.name(), + request.birthDate(), + request.email() + ); + return ApiResponse.success(MemberV1Dto.SignUpResponse.from(member)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java new file mode 100644 index 000000000..ca2906884 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.member; + +import com.loopers.domain.member.Member; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class MemberV1Dto { + + public record SignUpRequest( + @NotBlank(message = "๋กœ๊ทธ์ธID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String loginId, + + @NotBlank(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String password, + + @NotBlank(message = "์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String name, + + @NotBlank(message = "์ƒ๋…„์›”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + String birthDate, + + @NotBlank(message = "์ด๋ฉ”์ผ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Email(message = "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค") + String email + ) {} + + public record SignUpResponse(Long id, String loginId, String name, String birthDate, String email) { + public static SignUpResponse from(Member member) { + return new SignUpResponse( + member.getId(), + member.getLoginId(), + member.getName(), + member.getBirthDate(), + member.getEmail() + ); + } + } +} 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..5d6a0e31f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,17 @@ +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.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Order V1 API", description = "์ฃผ๋ฌธ API") +@RequestMapping("/api/v1/orders") +public interface OrderV1ApiSpec { + + @Operation(summary = "์ฃผ๋ฌธ ์ƒ์„ฑ", description = "์ƒํ’ˆ์„ ์ฃผ๋ฌธํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse createOrder(@Valid @RequestBody OrderV1Dto.OrderCreateRequest 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..24cc504a5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,32 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderService orderService; + + @PostMapping + @Override + public ApiResponse createOrder(@Valid @RequestBody OrderV1Dto.OrderCreateRequest request) { + List items = request.items().stream() + .map(item -> new OrderDomainService.OrderLineRequest(item.productId(), item.quantity())) + .collect(Collectors.toList()); + OrderService.OrderResult result = orderService.placeOrder(request.memberId(), items); + return ApiResponse.success(OrderV1Dto.OrderCreateResponse.from(result)); + } +} 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..044235ee0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class OrderV1Dto { + + public record OrderCreateRequest( + @NotNull(message = "ํšŒ์› ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long memberId, + + @Valid + @NotNull(message = "์ฃผ๋ฌธ ํ•ญ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + List items + ) {} + + public record OrderLineRequest( + @NotNull(message = "์ƒํ’ˆ ID๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + Long productId, + + @NotNull(message = "์ˆ˜๋Ÿ‰์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Min(value = 1, message = "์ˆ˜๋Ÿ‰์€ 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค") + Integer quantity + ) {} + + public record OrderCreateResponse( + Long orderId, + String status, + long totalAmount, + List orderLines + ) { + public static OrderCreateResponse from(OrderService.OrderResult result) { + List lines = result.orderLines().stream() + .map(ol -> new OrderLineResponse(ol.productId(), ol.quantity(), ol.unitPrice())) + .toList(); + return new OrderCreateResponse( + result.orderId(), + result.status(), + result.totalAmount(), + lines + ); + } + } + + public record OrderLineResponse(Long productId, int quantity, long unitPrice) {} +} 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..6f2e0e75d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.domain.product.SortCondition; +import com.loopers.interfaces.api.ApiResponse; +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 = "Product V1 API", description = "์ƒํ’ˆ API") +public interface ProductV1ApiSpec { + + @Operation(summary = "์ƒํ’ˆ ๋ชฉ๋ก ์กฐํšŒ", description = "์ •๋ ฌ ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ƒํ’ˆ ๋ชฉ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse> getProductList( + @Parameter(description = "์ •๋ ฌ ์กฐ๊ฑด: latest, price_asc, likes_desc") SortCondition sort + ); + + @Operation(summary = "์ƒํ’ˆ ์ƒ์„ธ ์กฐํšŒ", description = "ID๋กœ ์ƒํ’ˆ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + ApiResponse getProductDetail(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..842a57626 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductListInfo; +import com.loopers.domain.product.SortCondition; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse> getProductList( + @RequestParam(defaultValue = "latest") SortCondition sort + ) { + List productList = productFacade.getProductList(sort); + List response = productList.stream() + .map(ProductV1Dto.ProductListResponse::from) + .toList(); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProductDetail( + @PathVariable Long productId + ) { + ProductDetailInfo info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } +} 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..16a4b8a31 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductDetailInfo; +import com.loopers.application.product.ProductListInfo; + +public class ProductV1Dto { + + public record ProductDetailResponse( + Long id, + String name, + Long price, + int stockQuantity, + BrandResponse brand, + long likeCount + ) { + public static ProductDetailResponse from(ProductDetailInfo info) { + BrandResponse brandResponse = info.brand() != null + ? new BrandResponse(info.brand().id(), info.brand().name()) + : null; + return new ProductDetailResponse( + info.id(), + info.name(), + info.price(), + info.stockQuantity(), + brandResponse, + info.likeCount() + ); + } + } + + public record BrandResponse(Long id, String name) {} + + public record ProductListResponse( + Long id, + String name, + Long price, + String brandName, + long likeCount + ) { + public static ProductListResponse from(ProductListInfo info) { + return new ProductListResponse( + info.id(), + info.name(), + info.price(), + info.brandName(), + info.likeCount() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 5d142efbf..e32a6fc59 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -11,7 +11,8 @@ public enum ErrorType { INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "์ผ์‹œ์ ์ธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."); + CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + INSUFFICIENT_STOCK(HttpStatus.BAD_REQUEST, "INSUFFICIENT_STOCK", "์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java new file mode 100644 index 000000000..20b69ee00 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -0,0 +1,170 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +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 org.junit.jupiter.api.BeforeEach; +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 java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeServiceTest { + + private LikeService likeService; + private FakeLikeRepository fakeLikeRepository; + private FakeProductRepository fakeProductRepository; + + @BeforeEach + void setUp() { + fakeLikeRepository = new FakeLikeRepository(); + fakeProductRepository = new FakeProductRepository(); + likeService = new LikeService(fakeLikeRepository, fakeProductRepository); + } + + @DisplayName("์ข‹์•„์š” ๋“ฑ๋ก") + @Nested + class Like { + + @DisplayName("์กด์žฌํ•˜๋Š” ์ƒํ’ˆ์— ์ข‹์•„์š”๋ฅผ ๋“ฑ๋กํ•˜๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + + likeService.like(memberId, productId); + + assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, productId)).isTrue(); + } + + @DisplayName("์ด๋ฏธ ์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์— ๋‹ค์‹œ ์ข‹์•„์š”ํ•ด๋„ ๋ฉฑ๋“ฑํ•˜๊ฒŒ ๋™์ž‘ํ•œ๋‹ค") + @Test + void idempotent() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + + likeService.like(memberId, productId); + likeService.like(memberId, productId); + + assertThat(fakeLikeRepository.countByProductId(productId)).isEqualTo(1); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์— ์ข‹์•„์š”ํ•˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductNotFound() { + Long memberId = 1L; + Long nonExistentProductId = 999L; + + assertThatThrownBy(() -> likeService.like(memberId, nonExistentProductId)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + @DisplayName("์ข‹์•„์š” ์ทจ์†Œ") + @Nested + class Unlike { + + @DisplayName("์ข‹์•„์š”ํ•œ ์ƒํ’ˆ์˜ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•˜๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + fakeLikeRepository.save(new Like(memberId, productId)); + + likeService.unlike(memberId, productId); + + assertThat(fakeLikeRepository.existsByMemberIdAndProductId(memberId, productId)).isFalse(); + } + + @DisplayName("์ข‹์•„์š”ํ•˜์ง€ ์•Š์€ ์ƒํ’ˆ์˜ ์ข‹์•„์š”๋ฅผ ์ทจ์†Œํ•ด๋„ ์˜ˆ์™ธ ์—†์ด ๋™์ž‘ํ•œ๋‹ค") + @Test + void idempotent() { + Long memberId = 1L; + Long productId = 100L; + + likeService.unlike(memberId, productId); + + assertThat(fakeLikeRepository.countByProductId(productId)).isEqualTo(0); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + Product toSave = new Product( + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStockQuantity() + ); + long id = nextId++; + store.put(id, toSave); + return toSave; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public java.util.List findAll(com.loopers.domain.product.SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeLikeRepository implements LikeRepository { + private final List store = new ArrayList<>(); + private long id = 1; + + @Override + public Like save(Like like) { + Like toSave = new Like(like.getMemberId(), like.getProductId()); + store.add(toSave); + id++; + return toSave; + } + + @Override + public void deleteByMemberIdAndProductId(Long memberId, Long productId) { + store.removeIf(l -> l.getMemberId().equals(memberId) && l.getProductId().equals(productId)); + } + + @Override + public boolean existsByMemberIdAndProductId(Long memberId, Long productId) { + return store.stream() + .anyMatch(l -> l.getMemberId().equals(memberId) && l.getProductId().equals(productId)); + } + + @Override + public long countByProductId(Long productId) { + return store.stream() + .filter(l -> l.getProductId().equals(productId)) + .count(); + } + + @Override + public Map countByProductIds(List productIds) { + return productIds.stream() + .collect(java.util.stream.Collectors.toMap(id -> id, this::countByProductId)); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java new file mode 100644 index 000000000..607d88045 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -0,0 +1,108 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderServiceTest { + + private OrderService orderService; + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + OrderDomainService orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); + orderService = new OrderService(orderDomainService); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + @Nested + class PlaceOrder { + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + List items = List.of( + new OrderDomainService.OrderLineRequest(1L, 3) + ); + + OrderService.OrderResult result = orderService.placeOrder(memberId, items); + + assertThat(result.orderId()).isNotNull(); + assertThat(result.status()).isEqualTo("ORDERED"); + assertThat(result.totalAmount()).isEqualTo(30_000L); + assertThat(result.orderLines()).hasSize(1); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenInsufficientStock() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 2)); + + assertThatThrownBy(() -> orderService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(1L, 5) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + long id = nextId++; + store.put(id, product); + return product; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeOrderRepository implements OrderRepository { + private final List store = new ArrayList<>(); + + @Override + public com.loopers.domain.order.Order save(com.loopers.domain.order.Order order) { + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + } +} 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..cc803461e --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.like; + +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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class LikeTest { + + @DisplayName("์ข‹์•„์š”๋ฅผ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ํšŒ์›ID์™€ ์ƒํ’ˆID๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + Long productId = 100L; + + Like like = new Like(memberId, productId); + + assertAll( + () -> assertThat(like.getMemberId()).isEqualTo(memberId), + () -> assertThat(like.getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("ํšŒ์›ID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenMemberIdIsNull() { + assertThatThrownBy(() -> new Like(null, 1L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ƒํ’ˆID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductIdIsNull() { + assertThatThrownBy(() -> new Like(1L, null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java new file mode 100644 index 000000000..88792c22b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberRepositoryTest.java @@ -0,0 +1,118 @@ +package com.loopers.domain.member; + +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 java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class MemberRepositoryTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("ํšŒ์›์„ ์ €์žฅํ•  ๋•Œ") + @Nested + class Save { + + @DisplayName("์œ ํšจํ•œ ํšŒ์› ์ •๋ณด๋ฅผ ์ €์žฅํ•˜๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void saveMember() { + Member member = new Member( + "ymcho", + "ymcho123", + "์กฐ์šฉ๋ฏผ", + "1991-07-03", + "ymcho@example.com" + ); + + Member saved = memberRepository.save(member); + + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(saved.getLoginId()).isEqualTo("ymcho"), + () -> assertThat(saved.getName()).isEqualTo("์กฐ์šฉ๋ฏผ") + ); + } + } + + @DisplayName("๋กœ๊ทธ์ธID๋กœ ํšŒ์›์„ ์กฐํšŒํ•  ๋•Œ") + @Nested + class FindByLoginId { + + @DisplayName("์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธID๋กœ ์กฐํšŒํ•˜๋ฉด ํšŒ์›์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void findExistingMember() { + Member member = new Member( + "ymcho", + "ymcho123", + "์กฐ์šฉ๋ฏผ", + "1991-07-03", + "ymcho@example.com" + ); + memberRepository.save(member); + + Optional found = memberRepository.findByLoginId("ymcho"); + + assertAll( + () -> assertThat(found).isPresent(), + () -> assertThat(found.get().getLoginId()).isEqualTo("ymcho"), + () -> assertThat(found.get().getName()).isEqualTo("์กฐ์šฉ๋ฏผ") + ); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธID๋กœ ์กฐํšŒํ•˜๋ฉด ๋นˆ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void findNonExistingMember() { + Optional found = memberRepository.findByLoginId("nonexistent"); + + assertThat(found).isEmpty(); + } + } + + @DisplayName("๋กœ๊ทธ์ธID ์ค‘๋ณต์„ ํ™•์ธํ•  ๋•Œ") + @Nested + class ExistsByLoginId { + + @DisplayName("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋กœ๊ทธ์ธID๋ฉด true๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void existingLoginId() { + Member member = new Member( + "ymcho", + "ymcho123", + "์กฐ์šฉ๋ฏผ", + "1991-07-03", + "ymcho@example.com" + ); + memberRepository.save(member); + + boolean exists = memberRepository.existsByLoginId("ymcho"); + + assertThat(exists).isTrue(); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธID๋ฉด false๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + @Test + void nonExistingLoginId() { + boolean exists = memberRepository.existsByLoginId("nonexistent"); + + assertThat(exists).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java new file mode 100644 index 000000000..8ec239ca7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/member/MemberTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.member; + +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.*; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MemberTest { + + @DisplayName("ํšŒ์›์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + String loginId = "ymcho"; + String password = "ymcho123"; + String name = "์กฐ์šฉ๋ฏผ"; + String birthDate = "1991-07-03"; + String email = "ymcho@example.com"; + + Member member = new Member(loginId, password, name, birthDate, email); + + assertAll( + () -> assertThat(member.getLoginId()).isEqualTo(loginId), + () -> assertThat(member.getName()).isEqualTo(name), + () -> assertThat(member.getBirthDate()).isEqualTo(birthDate), + () -> assertThat(member.getEmail()).isEqualTo(email) + ); + } + + @DisplayName("๋กœ๊ทธ์ธID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenLoginIdIsNull() { + assertThatThrownBy(() -> + new Member(null, "ymcho123", "์กฐ์šฉ๋ฏผ", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("๋กœ๊ทธ์ธID๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenLoginIdIsBlank() { + assertThatThrownBy(() -> + new Member(" ", "ymcho123", "์กฐ์šฉ๋ฏผ", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenPasswordIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", null, "์กฐ์šฉ๋ฏผ", "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฆ„์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", null, "1991-07-03", "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ƒ๋…„์›”์ผ์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenBirthDateIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", "์กฐ์šฉ๋ฏผ", null, "ymcho@example.com") + ) + .isInstanceOf(CoreException.class); + } + + @DisplayName("์ด๋ฉ”์ผ์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenEmailIsNull() { + assertThatThrownBy(() -> + new Member("ymcho", "ymcho123", "์กฐ์šฉ๋ฏผ", "1991-07-03", null) + ) + .isInstanceOf(CoreException.class); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java new file mode 100644 index 000000000..6e761fc48 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java @@ -0,0 +1,136 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.SortCondition; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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 java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderDomainServiceTest { + + private OrderDomainService orderDomainService; + private FakeProductRepository fakeProductRepository; + private FakeOrderRepository fakeOrderRepository; + + @BeforeEach + void setUp() { + fakeProductRepository = new FakeProductRepository(); + fakeOrderRepository = new FakeOrderRepository(); + orderDomainService = new OrderDomainService(fakeProductRepository, fakeOrderRepository); + } + + @DisplayName("์ฃผ๋ฌธ ์ƒ์„ฑ") + @Nested + class PlaceOrder { + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ฃผ๋ฌธ์ด ์„ฑ๊ณตํ•˜๊ณ  ์žฌ๊ณ ๊ฐ€ ์ฐจ๊ฐ๋œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + Product product = fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 10)); + Long productId = 1L; + + Order order = orderDomainService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 3) + )); + + assertThat(order.getMemberId()).isEqualTo(memberId); + assertThat(order.getOrderLines()).hasSize(1); + assertThat(order.getTotalAmount()).isEqualTo(30_000L); + + Product updatedProduct = fakeProductRepository.findById(productId).orElseThrow(); + assertThat(updatedProduct.getStockQuantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด INSUFFICIENT_STOCK ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๊ณ  ์ฃผ๋ฌธ์ด ์ƒ์„ฑ๋˜์ง€ ์•Š๋Š”๋‹ค") + @Test + void failsWhenInsufficientStock() { + Long memberId = 1L; + fakeProductRepository.save(new Product(1L, "์ƒํ’ˆ", 10_000L, 5)); + Long productId = 1L; + + assertThatThrownBy(() -> orderDomainService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(productId, 10) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + + assertThat(fakeOrderRepository.count()).isEqualTo(0); + } + + @DisplayName("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ƒํ’ˆ์ด ํฌํ•จ๋˜๋ฉด NOT_FOUND ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductNotFound() { + Long memberId = 1L; + Long nonExistentProductId = 999L; + + assertThatThrownBy(() -> orderDomainService.placeOrder(memberId, List.of( + new OrderDomainService.OrderLineRequest(nonExistentProductId, 1) + ))) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND); + } + } + + static class FakeProductRepository implements ProductRepository { + private final Map store = new ConcurrentHashMap<>(); + private long nextId = 1; + + @Override + public Product save(Product product) { + Product toSave = new Product( + product.getBrandId(), + product.getName(), + product.getPrice(), + product.getStockQuantity() + ); + long id = nextId++; + store.put(id, toSave); + return toSave; + } + + @Override + public Optional findById(Long id) { + Product product = store.get(id); + if (product == null) return Optional.empty(); + return Optional.of(product); + } + + @Override + public List findAll(SortCondition sort) { + return new ArrayList<>(store.values()); + } + } + + static class FakeOrderRepository implements OrderRepository { + private final List store = new ArrayList<>(); + + @Override + public Order save(Order order) { + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + + int count() { + return store.size(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java new file mode 100644 index 000000000..cc80f4fe6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderLineTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.order; + +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.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderLineTest { + + @DisplayName("OrderLine์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ๊ฐ’์ด ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + OrderLine orderLine = new OrderLine(1L, 2, 10_000L); + + assertThat(orderLine.getTotalPrice()).isEqualTo(20_000L); + } + + @DisplayName("์ƒํ’ˆID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenProductIdIsNull() { + assertThatThrownBy(() -> new OrderLine(null, 1, 10_000L)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ˆ˜๋Ÿ‰์ด 0 ์ดํ•˜๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenQuantityIsZeroOrNegative() { + assertThatThrownBy(() -> new OrderLine(1L, 0, 10_000L)) + .isInstanceOf(CoreException.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..20fd0813d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.order; + +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.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OrderTest { + + @DisplayName("์ฃผ๋ฌธ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ํšŒ์›ID์™€ ์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long memberId = 1L; + List orderLines = List.of( + new OrderLine(1L, 2, 10_000L), + new OrderLine(2L, 1, 5_000L) + ); + + Order order = Order.create(memberId, orderLines); + + assertAll( + () -> assertThat(order.getMemberId()).isEqualTo(memberId), + () -> assertThat(order.getStatus()).isEqualTo("ORDERED"), + () -> assertThat(order.getOrderLines()).hasSize(2), + () -> assertThat(order.getTotalAmount()).isEqualTo(25_000L) + ); + } + + @DisplayName("ํšŒ์›ID๊ฐ€ null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenMemberIdIsNull() { + List orderLines = List.of(new OrderLine(1L, 1, 10_000L)); + + assertThatThrownBy(() -> Order.create(null, orderLines)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฃผ๋ฌธ ํ•ญ๋ชฉ์ด ๋น„์–ด์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenOrderLinesIsEmpty() { + assertThatThrownBy(() -> Order.create(1L, List.of())) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java new file mode 100644 index 000000000..144e7e308 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/BrandTest.java @@ -0,0 +1,47 @@ +package com.loopers.domain.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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class BrandTest { + + @DisplayName("๋ธŒ๋žœ๋“œ๋ฅผ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ด๋ฆ„์ด ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + String name = "๋‚˜์ดํ‚ค"; + + Brand brand = new Brand(name); + + assertAll( + () -> assertThat(brand.getName()).isEqualTo(name) + ); + } + + @DisplayName("์ด๋ฆ„์ด null์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsNull() { + assertThatThrownBy(() -> new Brand(null)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ด๋ฆ„์ด ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new Brand(" ")) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +} 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..9e4a99085 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,116 @@ +package com.loopers.domain.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.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +class ProductTest { + + @DisplayName("์ƒํ’ˆ์„ ์ƒ์„ฑํ•  ๋•Œ") + @Nested + class Create { + + @DisplayName("์œ ํšจํ•œ ์ •๋ณด๊ฐ€ ์ฃผ์–ด์ง€๋ฉด ์„ฑ๊ณตํ•œ๋‹ค") + @Test + void success() { + Long brandId = 1L; + String name = "์—์–ด๋งฅ์Šค"; + Long price = 150_000L; + int stockQuantity = 10; + + Product product = new Product(brandId, name, price, stockQuantity); + + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(brandId), + () -> assertThat(product.getName()).isEqualTo(name), + () -> assertThat(product.getPrice()).isEqualTo(price), + () -> assertThat(product.getStockQuantity()).isEqualTo(stockQuantity) + ); + } + + @DisplayName("์ƒํ’ˆ๋ช…์ด ๋น„์–ด์žˆ์œผ๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenNameIsBlank() { + assertThatThrownBy(() -> new Product(1L, " ", 1000L, 5)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("๊ฐ€๊ฒฉ์ด ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenPriceIsNegative() { + assertThatThrownBy(() -> new Product(1L, "์ƒํ’ˆ", -1L, 5)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenStockQuantityIsNegative() { + assertThatThrownBy(() -> new Product(1L, "์ƒํ’ˆ", 1000L, -1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } + + @DisplayName("์žฌ๊ณ ๋ฅผ ์ฐจ๊ฐํ•  ๋•Œ") + @Nested + class DecreaseStock { + + @DisplayName("์žฌ๊ณ ๊ฐ€ ์ถฉ๋ถ„ํ•˜๋ฉด ์ •์ƒ ์ฐจ๊ฐ๋œ๋‹ค") + @Test + void success() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 10); + + product.decreaseStock(3); + + assertThat(product.getStockQuantity()).isEqualTo(7); + } + + @DisplayName("์žฌ๊ณ ์™€ ๋™์ผํ•œ ์ˆ˜๋Ÿ‰์„ ์ฐจ๊ฐํ•˜๋ฉด 0์ด ๋œ๋‹ค") + @Test + void successWhenExactStock() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 5); + + product.decreaseStock(5); + + assertThat(product.getStockQuantity()).isEqualTo(0); + } + + @DisplayName("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•˜๋ฉด INSUFFICIENT_STOCK ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenInsufficientStock() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 5); + + assertThatThrownBy(() -> product.decreaseStock(10)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.INSUFFICIENT_STOCK); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด 0์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenQuantityIsZero() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 10); + + assertThatThrownBy(() -> product.decreaseStock(0)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + + @DisplayName("์ฐจ๊ฐ ์ˆ˜๋Ÿ‰์ด ์Œ์ˆ˜์ด๋ฉด ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค") + @Test + void failsWhenQuantityIsNegative() { + Product product = new Product(1L, "์ƒํ’ˆ", 10_000L, 10); + + assertThatThrownBy(() -> product.decreaseStock(-1)) + .isInstanceOf(CoreException.class) + .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST); + } + } +}