From 70384e14e1ad71ac61ee49609e1a200cb9f5458a Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Mon, 23 Feb 2026 21:40:52 +0900 Subject: [PATCH 01/13] =?UTF-8?q?docs:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=9A=B4=EC=98=81/=EB=8F=99=EC=8B=9C=EC=84=B1/?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B0=9C=EC=84=A0=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장바구니 최대 100개 제한 제거 - likes_count 동시성: @Version 낙관적 락 명시 (시퀀스 다이어그램, 클래스 다이어그램, 요구사항) - 주문번호 생성 전략: UUID 기반 (ORD-yyyyMMdd-{UUID 앞 8자리}) 명시 - 주문 취소 트랜잭션: @Transactional 원자적 처리 범위 명시 - 물리적 FK: ORDERS→USERS, ORDER_ITEMS→ORDERS 구간 DDL FK 제약 명시 - OrderFacade 추가: OrderService + CartService 조율로 관심사 분리 - CartService 추가: 장바구니 관리 책임 분리 - OrderService.generateOrderNumber() 추가 Co-Authored-By: Claude Sonnet 4.6 --- docs/design/01-requirements.md | 6 ++++-- docs/design/02-sequence-diagrams.md | 7 +++++-- docs/design/03-class-diagram.md | 24 ++++++++++++++++++++++-- docs/design/04-erd.md | 25 +++++++++++++++---------- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index cdde08f30..72a8aedfb 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -65,7 +65,6 @@ 핵심 제약: - 동일 상품 중복 담기 시 수량 합산 -- 장바구니 최대 상품 개수: 100개 [시나리오 4] 상품 주문 @@ -179,9 +178,12 @@ - 좋아요는 중복 등록을 방지한다. - 삭제는 soft delete 정책을 따른다. - 모든 조회 API는 페이징을 지원한다. -- 장바구니 최대 상품 개수: 100개 - 비밀번호 암호화 알고리즘: BCrypt - 인증 방식: 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) +- likes_count 동시성: Product 엔티티의 @Version 낙관적 락으로 동시 갱신 충돌을 방지한다. +- 주문번호 생성: UUID 기반 (ORD-yyyyMMdd-{UUID 앞 8자리})으로 중복을 방지한다. +- 주문 취소 시 재고 복구와 상태 업데이트는 단일 @Transactional 범위에서 원자적으로 처리한다. +- ORDERS → USERS, ORDER_ITEMS → ORDERS 관계는 물리적 FK 제약조건으로 참조 무결성을 보장한다. diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 52c90087a..726b160ec 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -27,7 +27,8 @@ participant ProdRepo as Product Repository Repo-->>Service: 결과 반환 alt 미존재 Service->>Repo: INSERT (Like) - Service->>ProdRepo: UPDATE (likes_count + 1) + Service->>ProdRepo: UPDATE (likes_count + 1) — @Version 낙관적 락 + Note right of Service: OptimisticLockException 발생 시 재시도 Service-->>API: 성공 else 이미 존재 Service-->>API: 400 Bad Request @@ -35,7 +36,7 @@ participant ProdRepo as Product Repository else 취소 요청 Service->>Repo: DELETE (Like) Note right of Service: 삭제 성공 시에만 count 감소 - Service->>ProdRepo: UPDATE (likes_count - 1) + Service->>ProdRepo: UPDATE (likes_count - 1) — @Version 낙관적 락 Service-->>API: 성공 end @@ -68,6 +69,7 @@ title 주문 생성 (재고 차감 및 장바구니 삭제) API->>Service: 주문 생성 트랜잭션 시작 activate Service + Service->>Service: generateOrderNumber() — UUID 기반 주문번호 생성 Service->>ProdRepo: 재고 차감 요청 ProdRepo-->>Service: 성공 여부 반환 @@ -122,6 +124,7 @@ title 주문 생성 (재고 차감 및 장바구니 삭제) alt 관리자 승인 (Approve) S->>OR: 상태를 'CONFIRMED'로 업데이트 else 사용자 취소 (Cancel) + Note over S,PR: @Transactional — 재고 복구 + 상태 업데이트 원자적 처리 S->>PR: 재고 복구 (stock + n) S->>OR: 상태를 'CANCELLED'로 업데이트 end diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 6a14eaa3a..7400fa3ea 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -56,6 +56,7 @@ classDiagram +updateQuantity(quantity) void } + class Order { <> +Long id @@ -110,6 +111,12 @@ classDiagram +removeLike(userId, productId) void } + class CartService { + <> + +addItem(userId, productId, quantity) void + +removeByUser(userId) void + } + class OrderService { <> +createOrder(userId, items) Order @@ -117,12 +124,25 @@ classDiagram +cancelOrder(orderNumber) void +getOrders(userId, pageable) Page~Order~ +getOrderDetail(orderId) Order + +generateOrderNumber() String + } + + %% ============================================ + %% Facade 정의 + %% ============================================ + + class OrderFacade { + <> + +createOrder(userId, items) Order + +cancelOrder(orderNumber) void } - LikeService --> Product : likesCount 증감 + LikeService --> Product : likesCount 증감 (@Version 낙관적 락) + OrderFacade --> OrderService : 주문 처리 위임 + OrderFacade --> CartService : 장바구니 삭제 위임 OrderService --> Product : 재고 차감/복구 - OrderService --> Cart : 주문 시 장바구니 삭제 OrderService --> OrderItem : 스냅샷 저장 + CartService --> Cart : 장바구니 관리 ``` diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 5488ae51e..8c4a45128 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -66,7 +66,7 @@ erDiagram %% ======================================== ORDERS { bigint id PK "자동증가" - varchar(50) order_number UK "주문번호 (중복불가, ORD-yyyyMMdd-xxxxx)" + varchar(50) order_number UK "주문번호 (UUID 기반, ORD-yyyyMMdd-{UUID 앞 8자리})" bigint user_id "주문자 ID (조회 최적화용 인덱스)" int total_amount "총 주문 금액" varchar(20) order_status "주문 상태 (PENDING/CONFIRMED/CANCELLED)" @@ -88,23 +88,28 @@ erDiagram } %% ======================================== - %% 논리적 관계 정의 (물리적 FK 제약조건 없음) + %% 관계 정의 + %% [물리적 FK] ORDERS.user_id → USERS, ORDER_ITEMS.order_id → ORDERS + %% 트랜잭션 정합성 필수 구간 — DDL에 FK 제약조건 명시 + %% [논리적 FK] 나머지 관계는 애플리케이션 레벨에서 검증 %% ======================================== - - %% 브랜드 → 상품 + + %% 브랜드 → 상품 (논리적 FK) BRANDS ||--o{ PRODUCTS : "보유" - - %% 사용자 → 좋아요/장바구니/주문 + + %% 사용자 → 좋아요/장바구니 (논리적 FK) USERS ||--o{ LIKES : "좋아요" USERS ||--o{ CART : "장바구니담기" + + %% 사용자 → 주문 (물리적 FK) USERS ||--o{ ORDERS : "주문" - - %% 상품 → 좋아요/장바구니/주문항목 + + %% 상품 → 좋아요/장바구니/주문항목 (논리적 FK) PRODUCTS ||--o{ LIKES : "좋아요받음" PRODUCTS ||--o{ CART : "담김" PRODUCTS ||--o{ ORDER_ITEMS : "주문됨" - - %% 주문 → 주문항목 + + %% 주문 → 주문항목 (물리적 FK) ORDERS ||--|{ ORDER_ITEMS : "포함" ``` \ No newline at end of file From 4c59dda639386d7b7ac603297e38046c94005777 Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Mon, 23 Feb 2026 21:58:05 +0900 Subject: [PATCH 02/13] =?UTF-8?q?docs:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=9E=94=EC=95=A1?= =?UTF-8?q?(balance)=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장바구니(Cart) 도메인 전체 제거 (요구사항, 시퀀스, 클래스, ERD) - User에 balance 필드 추가 (USERS 테이블 포함) - 주문 생성 시퀀스: 잔액 확인 → 잔액 차감 흐름 추가 - 주문 취소 시퀀스: 잔액 복구 단계 추가 (UserRepo 참여자 추가) - 기능 요구사항: 잔액 조회, 잔액 확인/차감/복구 항목 추가 - 시나리오 3 → 상품 주문으로 변경 (장바구니 시나리오 삭제) - 핵심 제약: 잔액 부족 시 주문 실패 추가 Co-Authored-By: Claude Sonnet 4.6 --- Round3.md | 0 docs/design/01-requirements.md | 50 ++++++++--------------------- docs/design/02-sequence-diagrams.md | 37 ++++++++++++--------- docs/design/03-class-diagram.md | 20 ++---------- docs/design/04-erd.md | 18 +++-------- 5 files changed, 41 insertions(+), 84 deletions(-) create mode 100644 Round3.md diff --git a/Round3.md b/Round3.md new file mode 100644 index 000000000..e69de29bb diff --git a/docs/design/01-requirements.md b/docs/design/01-requirements.md index 72a8aedfb..47329a7f2 100644 --- a/docs/design/01-requirements.md +++ b/docs/design/01-requirements.md @@ -46,28 +46,7 @@ - 좋아요 취소 시 likes_count 감소 -[시나리오 3] 장바구니 담기 및 관리 - -설명: -사용자는 관심 상품을 장바구니에 담고, 수량을 조절하거나 삭제한다. - -흐름: -1. 상품 상세 페이지에서 장바구니 담기 -2. 장바구니 목록 조회 -3. 수량 변경 -4. 장바구니에서 삭제 - -관련 기능: -- 장바구니 담기 -- 장바구니 목록 조회 -- 장바구니 수량 변경 -- 장바구니 삭제 - -핵심 제약: -- 동일 상품 중복 담기 시 수량 합산 - - -[시나리오 4] 상품 주문 +[시나리오 3] 상품 주문 설명: 사용자가 원하는 상품을 주문한다. @@ -76,9 +55,10 @@ 1. 상품 상세 조회 2. 주문 요청 3. 재고 확인 -4. 주문 생성 -5. 주문 목록 조회 -6. 단일 주문 상세 조회 +4. 잔액 확인 +5. 주문 생성 (재고 차감 + 잔액 차감) +6. 주문 목록 조회 +7. 단일 주문 상세 조회 관련 기능: - 주문 생성 @@ -87,10 +67,11 @@ 핵심 제약: - 재고 부족 시 주문 실패 -- 주문 생성과 재고 차감은 하나의 트랜잭션으로 처리 +- 잔액 부족 시 주문 실패 +- 주문 생성, 재고 차감, 잔액 차감은 하나의 트랜잭션으로 처리 - 주문 시점의 상품 정보는 스냅샷으로 저장 - 사용자는 PENDING 상태의 주문을 취소할 수 있다. -- 주문 취소 시 재고가 복구된다. +- 주문 취소 시 재고와 잔액이 복구된다. [시나리오 5] 관리자 상품 및 주문 관리 @@ -131,6 +112,7 @@ - 사용자는 회원가입을 할 수 있다. - 사용자는 자신의 정보를 조회할 수 있다. - 사용자는 비밀번호를 변경할 수 있다. +- 사용자는 자신의 잔액을 조회할 수 있다. 상품 조회: - 사용자는 전체 상품 목록을 조회할 수 있다. @@ -144,22 +126,16 @@ - 사용자는 좋아요를 취소할 수 있다. - 사용자는 자신이 좋아요한 상품 목록을 조회할 수 있다. -장바구니: -- 사용자는 상품을 장바구니에 담을 수 있다. -- 사용자는 장바구니 목록을 조회할 수 있다. -- 사용자는 장바구니 상품의 수량을 변경할 수 있다. -- 사용자는 장바구니에서 상품을 삭제할 수 있다. -- 동일 상품 중복 담기 시 수량이 합산된다. - 주문: - 사용자는 상품을 주문할 수 있다. - 주문 시 재고를 확인해야 한다. +- 주문 시 유저의 잔액이 충분한지 확인한다. - 주문 성공 시 재고가 차감된다. -- 주문 성공 시 장바구니 데이터가 삭제된다. +- 주문 성공 시 잔액이 차감된다. - 사용자는 자신의 주문 목록을 조회할 수 있다. - 사용자는 단일 주문 상세 정보를 조회할 수 있다. - 사용자는 PENDING 상태의 주문을 취소할 수 있다. -- 주문 취소 시 재고가 복구된다. +- 주문 취소 시 재고와 잔액이 복구된다. 관리자: - 관리자는 브랜드를 등록/수정/삭제할 수 있다. @@ -182,7 +158,7 @@ - 인증 방식: 헤더 기반 인증 (X-Loopers-LoginId, X-Loopers-LoginPw) - likes_count 동시성: Product 엔티티의 @Version 낙관적 락으로 동시 갱신 충돌을 방지한다. - 주문번호 생성: UUID 기반 (ORD-yyyyMMdd-{UUID 앞 8자리})으로 중복을 방지한다. -- 주문 취소 시 재고 복구와 상태 업데이트는 단일 @Transactional 범위에서 원자적으로 처리한다. +- 주문 취소 시 재고 복구, 잔액 복구, 상태 업데이트는 단일 @Transactional 범위에서 원자적으로 처리한다. - ORDERS → USERS, ORDER_ITEMS → ORDERS 관계는 물리적 FK 제약조건으로 참조 무결성을 보장한다. diff --git a/docs/design/02-sequence-diagrams.md b/docs/design/02-sequence-diagrams.md index 726b160ec..e881b88b5 100644 --- a/docs/design/02-sequence-diagrams.md +++ b/docs/design/02-sequence-diagrams.md @@ -54,39 +54,44 @@ participant ProdRepo as Product Repository ```mermaid sequenceDiagram -title 주문 생성 (재고 차감 및 장바구니 삭제) +title 주문 생성 (재고 차감 및 잔액 차감) actor User as 사용자 participant API as Order API participant Service as Order Service + participant UserRepo as User Repository participant ProdRepo as Product Repository participant OrderRepo as Order Repository - participant CartRepo as Cart Repository - User->>API: 주문 생성 요청 + User->>API: 주문 생성 요청 (items: [{productId, quantity}, ...]) activate API API->>Service: 주문 생성 트랜잭션 시작 activate Service Service->>Service: generateOrderNumber() — UUID 기반 주문번호 생성 - Service->>ProdRepo: 재고 차감 요청 - + Service->>ProdRepo: 재고 확인 및 차감 요청 ProdRepo-->>Service: 성공 여부 반환 alt 재고 부족 Service-->>API: 재고 부족 예외 던짐 API-->>User: 400 Bad Request (품절) else 재고 충분 및 차감 완료 - Service->>OrderRepo: 주문(Order) & 상세(OrderItems) 저장 - OrderRepo-->>Service: 저장 완료 - - Service->>CartRepo: 장바구니 데이터 삭제 - CartRepo-->>Service: 삭제 완료 - - Service-->>API: 주문 성공 응답 - deactivate Service - API-->>User: 201 Created (주문 완료) + Service->>UserRepo: 잔액 확인 요청 + UserRepo-->>Service: 현재 잔액 반환 + + alt 잔액 부족 + Service-->>API: 잔액 부족 예외 던짐 + API-->>User: 400 Bad Request (잔액 부족) + else 잔액 충분 + Service->>UserRepo: 잔액 차감 (balance - totalAmount) + Service->>OrderRepo: 주문(Order) & 상세(OrderItems) 저장 + OrderRepo-->>Service: 저장 완료 + + Service-->>API: 주문 성공 응답 + deactivate Service + API-->>User: 201 Created (주문 완료) + end end deactivate API ``` @@ -107,6 +112,7 @@ title 주문 생성 (재고 차감 및 장바구니 삭제) participant S as Order Service participant OR as Order Repository participant PR as Product Repository + participant UR as User Repository U->>API: 상태 변경 요청 (Approve/Cancel) activate API @@ -124,8 +130,9 @@ title 주문 생성 (재고 차감 및 장바구니 삭제) alt 관리자 승인 (Approve) S->>OR: 상태를 'CONFIRMED'로 업데이트 else 사용자 취소 (Cancel) - Note over S,PR: @Transactional — 재고 복구 + 상태 업데이트 원자적 처리 + Note over S,UR: @Transactional — 재고 복구 + 잔액 복구 + 상태 업데이트 원자적 처리 S->>PR: 재고 복구 (stock + n) + S->>UR: 잔액 복구 (balance + totalAmount) S->>OR: 상태를 'CANCELLED'로 업데이트 end S-->>API: 성공 응답 diff --git a/docs/design/03-class-diagram.md b/docs/design/03-class-diagram.md index 7400fa3ea..c82c6c07e 100644 --- a/docs/design/03-class-diagram.md +++ b/docs/design/03-class-diagram.md @@ -16,6 +16,7 @@ classDiagram +String password +String name +String email + +Long balance } class Brand { @@ -47,14 +48,6 @@ classDiagram +Long productId } - class Cart { - <> - +Long id - +Long userId - +Long productId - +Integer quantity - +updateQuantity(quantity) void - } class Order { @@ -93,10 +86,8 @@ classDiagram Brand "1" --> "N" Product : 보유 User "1" --> "N" Like : 좋아요 - User "1" --> "N" Cart : 장바구니 User "1" --> "N" Order : 주문 Product "1" --> "N" Like : 받음 - Product "1" --> "N" Cart : 담김 Product "1" --> "N" OrderItem : 주문됨 Order "1" --> "N" OrderItem : 포함 Order --> OrderStatus : 상태 @@ -111,12 +102,6 @@ classDiagram +removeLike(userId, productId) void } - class CartService { - <> - +addItem(userId, productId, quantity) void - +removeByUser(userId) void - } - class OrderService { <> +createOrder(userId, items) Order @@ -139,10 +124,9 @@ classDiagram LikeService --> Product : likesCount 증감 (@Version 낙관적 락) OrderFacade --> OrderService : 주문 처리 위임 - OrderFacade --> CartService : 장바구니 삭제 위임 OrderService --> Product : 재고 차감/복구 + OrderService --> User : 잔액 확인/차감/복구 OrderService --> OrderItem : 스냅샷 저장 - CartService --> Cart : 장바구니 관리 ``` diff --git a/docs/design/04-erd.md b/docs/design/04-erd.md index 8c4a45128..7c19e1680 100644 --- a/docs/design/04-erd.md +++ b/docs/design/04-erd.md @@ -10,6 +10,7 @@ erDiagram varchar(100) name "사용자 이름" varchar(255) email "이메일" varchar(20) phone "연락처" + bigint balance "잔액 (0 이상)" timestamp created_at "가입일시" timestamp updated_at "수정일시" timestamp deleted_at "삭제일시 (soft delete)" @@ -43,7 +44,7 @@ erDiagram } %% ======================================== - %% 좋아요 / 장바구니 + %% 좋아요 %% ======================================== LIKES { bigint id PK "자동증가" @@ -52,15 +53,6 @@ erDiagram timestamp created_at "좋아요 등록일시" } - CART { - bigint id PK "자동증가" - bigint user_id UK "사용자 ID (user_id+product_id 복합 중복불가)" - bigint product_id UK "상품 ID (user_id+product_id 복합 중복불가)" - int quantity "수량 (1 이상)" - timestamp created_at "장바구니 담은 일시" - timestamp updated_at "수량 수정일시" - } - %% ======================================== %% 주문 영역 %% ======================================== @@ -97,16 +89,14 @@ erDiagram %% 브랜드 → 상품 (논리적 FK) BRANDS ||--o{ PRODUCTS : "보유" - %% 사용자 → 좋아요/장바구니 (논리적 FK) + %% 사용자 → 좋아요 (논리적 FK) USERS ||--o{ LIKES : "좋아요" - USERS ||--o{ CART : "장바구니담기" %% 사용자 → 주문 (물리적 FK) USERS ||--o{ ORDERS : "주문" - %% 상품 → 좋아요/장바구니/주문항목 (논리적 FK) + %% 상품 → 좋아요/주문항목 (논리적 FK) PRODUCTS ||--o{ LIKES : "좋아요받음" - PRODUCTS ||--o{ CART : "담김" PRODUCTS ||--o{ ORDER_ITEMS : "주문됨" %% 주문 → 주문항목 (물리적 FK) From 72f7ed6c8f555774b9c7f52be5510e9ea53d980b Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Mon, 23 Feb 2026 22:57:32 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20User/Brand/Product/Like/Order=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=ED=98=84=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User: balance 추가, deductBalance/restoreBalance, authenticate() - Brand: 등록/조회/삭제 CRUD - Product: @Version 낙관적 락, decreaseStock/increaseStock, likesCount - Like: 중복 방지(CONFLICT), 취소(NOT_FOUND), Product.likesCount 연동 - Order/OrderItem: UUID 주문번호, PENDING/CONFIRMED/CANCELLED 상태 관리 - OrderFacade: 재고 차감 + 잔액 차감 단일 트랜잭션, 취소 시 원복 - 각 도메인 단위 테스트 (LikeTest, LikeServiceTest, OrderTest, OrderServiceTest 등) Co-Authored-By: Claude Sonnet 4.6 --- .../application/brand/BrandFacade.java | 35 ++++ .../loopers/application/brand/BrandInfo.java | 10 ++ .../loopers/application/like/LikeFacade.java | 36 ++++ .../application/order/OrderFacade.java | 97 ++++++++++ .../loopers/application/order/OrderInfo.java | 26 +++ .../application/order/OrderItemInfo.java | 21 +++ .../application/order/OrderRequest.java | 12 ++ .../application/product/ProductFacade.java | 69 +++++++ .../application/product/ProductInfo.java | 30 ++++ .../loopers/application/user/UserInfo.java | 8 +- .../java/com/loopers/domain/brand/Brand.java | 31 ++++ .../loopers/domain/brand/BrandRepository.java | 13 ++ .../loopers/domain/brand/BrandService.java | 49 +++++ .../java/com/loopers/domain/like/Like.java | 38 ++++ .../loopers/domain/like/LikeRepository.java | 12 ++ .../com/loopers/domain/like/LikeService.java | 29 +++ .../java/com/loopers/domain/order/Order.java | 60 +++++++ .../com/loopers/domain/order/OrderItem.java | 38 ++++ .../domain/order/OrderItemRepository.java | 10 ++ .../loopers/domain/order/OrderRepository.java | 15 ++ .../loopers/domain/order/OrderService.java | 66 +++++++ .../com/loopers/domain/order/OrderStatus.java | 7 + .../com/loopers/domain/product/Product.java | 99 +++++++++++ .../domain/product/ProductRepository.java | 12 ++ .../domain/product/ProductService.java | 48 +++++ .../java/com/loopers/domain/user/User.java | 14 ++ .../loopers/domain/user/UserRepository.java | 1 + .../com/loopers/domain/user/UserService.java | 8 +- .../brand/BrandJpaRepository.java | 15 ++ .../brand/BrandRepositoryImpl.java | 42 +++++ .../like/LikeJpaRepository.java | 11 ++ .../like/LikeRepositoryImpl.java | 30 ++++ .../order/OrderItemJpaRepository.java | 11 ++ .../order/OrderItemRepositoryImpl.java | 25 +++ .../order/OrderJpaRepository.java | 15 ++ .../order/OrderRepositoryImpl.java | 32 ++++ .../product/ProductJpaRepository.java | 14 ++ .../product/ProductRepositoryImpl.java | 35 ++++ .../user/UserJpaRepository.java | 10 ++ .../user/UserRepositoryImpl.java | 30 ++++ .../api/brand/BrandV1Controller.java | 49 +++++ .../interfaces/api/brand/BrandV1Dto.java | 15 ++ .../interfaces/api/like/LikeV1Controller.java | 40 +++++ .../interfaces/api/like/LikeV1Dto.java | 8 + .../api/order/OrderV1Controller.java | 74 ++++++++ .../interfaces/api/order/OrderV1Dto.java | 64 +++++++ .../api/product/ProductV1Controller.java | 71 ++++++++ .../interfaces/api/product/ProductV1Dto.java | 49 +++++ .../interfaces/api/user/UserV1Dto.java | 4 +- .../domain/brand/BrandServiceTest.java | 154 ++++++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 45 +++++ .../loopers/domain/like/LikeServiceTest.java | 98 ++++++++++ .../com/loopers/domain/like/LikeTest.java | 47 +++++ .../domain/order/OrderServiceTest.java | 168 ++++++++++++++++++ .../com/loopers/domain/order/OrderTest.java | 100 +++++++++++ .../domain/product/ProductServiceTest.java | 144 +++++++++++++++ .../loopers/domain/product/ProductTest.java | 123 +++++++++++++ .../loopers/domain/user/UserServiceTest.java | 57 ++++++ .../com/loopers/domain/user/UserTest.java | 56 ++++++ .../user/UserV1ControllerStandaloneTest.java | 2 +- 60 files changed, 2525 insertions(+), 7 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..d79a13f70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,35 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + + public BrandInfo register(String name, String description) { + Brand brand = brandService.register(name, description); + return BrandInfo.from(brand); + } + + public BrandInfo getBrand(Long id) { + Brand brand = brandService.getBrand(id); + return BrandInfo.from(brand); + } + + public List getBrands() { + return brandService.getBrands().stream() + .map(BrandInfo::from) + .toList(); + } + + public void deleteBrand(Long id) { + brandService.deleteBrand(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..5e848e729 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,10 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +public record BrandInfo(Long id, String name, String description) { + + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName(), brand.getDescription()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..91ba933c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,36 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + private final UserService userService; + + @Transactional + public void addLike(String loginId, String rawPassword, Long productId) { + User user = userService.authenticate(loginId, rawPassword); + Product product = productService.getProduct(productId); + likeService.addLike(user.getId(), productId); + product.increaseLikes(); + } + + @Transactional + public void removeLike(String loginId, String rawPassword, Long productId) { + User user = userService.authenticate(loginId, rawPassword); + Product product = productService.getProduct(productId); + likeService.removeLike(user.getId(), productId); + product.decreaseLikes(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..4c1c943fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,97 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final UserService userService; + + @Transactional + public OrderInfo createOrder(String loginId, String rawPassword, + List items) { + User user = userService.authenticate(loginId, rawPassword); + + List products = new ArrayList<>(); + long totalAmount = 0L; + for (OrderRequest.OrderItemRequest item : items) { + Product product = productService.getProduct(item.productId()); + product.decreaseStock(item.quantity()); + totalAmount += (long) product.getPrice() * item.quantity(); + products.add(product); + } + + user.deductBalance(totalAmount); + + String orderNumber = orderService.generateOrderNumber(); + Order order = orderService.createOrder(user.getId(), orderNumber, totalAmount); + + for (int i = 0; i < items.size(); i++) { + Product product = products.get(i); + OrderRequest.OrderItemRequest item = items.get(i); + orderService.createOrderItem(order.getId(), product.getId(), + product.getName(), product.getPrice(), item.quantity()); + } + + List orderItems = orderService.getOrderItems(order.getId()); + return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList()); + } + + @Transactional(readOnly = true) + public Page getOrders(String loginId, String rawPassword, Pageable pageable) { + User user = userService.authenticate(loginId, rawPassword); + Page orders = orderService.getOrders(user.getId(), pageable); + return orders.map(order -> { + List items = orderService.getOrderItems(order.getId()); + return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); + }); + } + + @Transactional(readOnly = true) + public OrderInfo getOrderDetail(String loginId, String rawPassword, Long orderId) { + userService.authenticate(loginId, rawPassword); + Order order = orderService.getOrder(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); + } + + @Transactional + public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) { + User user = userService.authenticate(loginId, rawPassword); + Order order = orderService.cancelOrder(orderId); + + List orderItems = orderService.getOrderItems(orderId); + for (OrderItem item : orderItems) { + Product product = productService.getProduct(item.getProductId()); + product.increaseStock(item.getQuantity()); + } + + user.restoreBalance(order.getTotalAmount()); + + return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList()); + } + + @Transactional + public OrderInfo approveOrder(Long orderId) { + Order order = orderService.approveOrder(orderId); + List items = orderService.getOrderItems(orderId); + return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..d1d03ddaf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,26 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +public record OrderInfo( + Long id, + Long userId, + String orderNumber, + OrderStatus status, + Long totalAmount, + List items +) { + public static OrderInfo from(Order order, List items) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getOrderNumber(), + order.getStatus(), + order.getTotalAmount(), + items + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..390b3baa3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,21 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderItem; + +public record OrderItemInfo( + Long id, + Long productId, + String productName, + Integer price, + Integer quantity +) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getId(), + item.getProductId(), + item.getProductName(), + item.getPrice(), + item.getQuantity() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java new file mode 100644 index 000000000..b93d68d15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java @@ -0,0 +1,12 @@ +package com.loopers.application.order; + +import java.util.List; + +public record OrderRequest( + List items +) { + public record OrderItemRequest( + Long productId, + Integer quantity + ) {} +} 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..1c2086187 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,69 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + public ProductInfo createProduct(Long brandId, String name, Integer price, Integer stock, + String description, String imageUrl) { + Brand brand = brandService.getBrand(brandId); + Product product = productService.createProduct(brandId, name, price, stock, description, imageUrl); + return ProductInfo.from(product, brand); + } + + public ProductInfo getProductDetail(Long id) { + Product product = productService.getProduct(id); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand); + } + + public Page getProducts(Long brandId, String sort, Pageable pageable) { + Pageable sortedPageable = PageRequest.of( + pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort) + ); + Page products = productService.getProducts(brandId, sortedPageable); + + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + + return products.map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId()))); + } + + public ProductInfo updateProduct(Long id, String name, Integer price, Integer stock, + String description, String imageUrl) { + Product product = productService.updateProduct(id, name, price, stock, description, imageUrl); + Brand brand = brandService.getBrand(product.getBrandId()); + return ProductInfo.from(product, brand); + } + + public void deleteProduct(Long id) { + productService.deleteProduct(id); + } + + private Sort resolveSort(String sort) { + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likesCount"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..a867ef0db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,30 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +public record ProductInfo( + Long id, + Long brandId, + String brandName, + String name, + Integer price, + Integer stock, + Integer likesCount, + String description, + String imageUrl +) { + public static ProductInfo from(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getBrandId(), + brand.getName(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getLikesCount(), + product.getDescription(), + product.getImageUrl() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java index dbfcf4789..bf09becc3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -2,14 +2,15 @@ import com.loopers.domain.user.User; -public record UserInfo(String loginId, String name, String birthday, String email) { +public record UserInfo(String loginId, String name, String birthday, String email, Long balance) { public static UserInfo from(User user) { return new UserInfo( user.getLoginId(), user.getName(), user.getBirthday(), - user.getEmail() + user.getEmail(), + user.getBalance() ); } @@ -18,7 +19,8 @@ public static UserInfo fromWithMaskedName(User user) { user.getLoginId(), user.getMaskedName(), user.getBirthday(), - user.getEmail() + user.getEmail(), + user.getBalance() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..7047d89c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,31 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "brands") +@Getter +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false, unique = true, length = 100) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + protected Brand() {} + + public Brand(String name, String description) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + this.name = name; + this.description = description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..910967cac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.brand; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + Optional findById(Long id); + Optional findByName(String name); + List findAll(); + List findAllByIds(Collection ids); + Brand save(Brand brand); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..678fa6ad1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,49 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class BrandService { + + private final BrandRepository brandRepository; + + @Transactional + public Brand register(String name, String description) { + brandRepository.findByName(name).ifPresent(b -> { + throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); + }); + return brandRepository.save(new Brand(name, description)); + } + + @Transactional(readOnly = true) + public Brand getBrand(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getBrands() { + return brandRepository.findAll(); + } + + @Transactional(readOnly = true) + public List getBrandsByIds(Collection ids) { + return brandRepository.findAllByIds(ids); + } + + @Transactional + public void deleteBrand(Long id) { + Brand brand = brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + brand.delete(); + brandRepository.save(brand); + } +} 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..688a16263 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,38 @@ +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.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +@Entity +@Table( + name = "likes", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) +) +@Getter +public class Like extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + protected Like() {} + + public Like(Long userId, Long productId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "userId는 null일 수 없습니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 null일 수 없습니다."); + } + this.userId = userId; + this.productId = 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..32791a1b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.like; + +import java.util.Optional; + +public interface LikeRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + Like save(Like like); + + void delete(Like like); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..49540b5a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +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; + + @Transactional + public Like addLike(Long userId, Long productId) { + likeRepository.findByUserIdAndProductId(userId, productId).ifPresent(l -> { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); + }); + return likeRepository.save(new Like(userId, productId)); + } + + @Transactional + public void removeLike(Long userId, Long productId) { + Like like = likeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요를 찾을 수 없습니다.")); + likeRepository.delete(like); + } +} 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..d5f70267f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,60 @@ +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.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "orders") +@Getter +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "order_number", nullable = false, unique = true, length = 50) + private String orderNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OrderStatus status; + + @Column(name = "total_amount", nullable = false) + private Long totalAmount; + + protected Order() {} + + public Order(Long userId, String orderNumber, Long totalAmount) { + if (totalAmount == null || totalAmount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 0보다 커야 합니다."); + } + this.userId = userId; + this.orderNumber = orderNumber; + this.totalAmount = totalAmount; + this.status = OrderStatus.PENDING; + } + + public void approve() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "대기 중인 주문만 승인할 수 있습니다."); + } + this.status = OrderStatus.CONFIRMED; + } + + public void cancel() { + if (this.status != OrderStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "대기 중인 주문만 취소할 수 있습니다."); + } + this.status = OrderStatus.CANCELLED; + } + + public boolean isPending() { + return this.status == OrderStatus.PENDING; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..58180d4fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,38 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "order_items") +@Getter +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "quantity", nullable = false) + private Integer quantity; + + protected OrderItem() {} + + public OrderItem(Long orderId, Long productId, String productName, Integer price, Integer quantity) { + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.price = price; + this.quantity = quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..65e3c7e02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + List findByOrderId(Long orderId); + + OrderItem save(OrderItem orderItem); +} 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..8f43beb91 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface OrderRepository { + + Optional findById(Long id); + + Page findByUserId(Long userId, Pageable pageable); + + Order save(Order order); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..ebfa9599e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,66 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + @Transactional + public Order createOrder(Long userId, String orderNumber, Long totalAmount) { + return orderRepository.save(new Order(userId, orderNumber, totalAmount)); + } + + @Transactional + public OrderItem createOrderItem(Long orderId, Long productId, String productName, + Integer price, Integer quantity) { + return orderItemRepository.save(new OrderItem(orderId, productId, productName, price, quantity)); + } + + @Transactional(readOnly = true) + public Order getOrder(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getOrders(Long userId, Pageable pageable) { + return orderRepository.findByUserId(userId, pageable); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long orderId) { + return orderItemRepository.findByOrderId(orderId); + } + + @Transactional + public Order cancelOrder(Long id) { + Order order = getOrder(id); + order.cancel(); + return orderRepository.save(order); + } + + @Transactional + public Order approveOrder(Long id) { + Order order = getOrder(id); + order.approve(); + return orderRepository.save(order); + } + + public String generateOrderNumber() { + String date = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + String uuid = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + return "ORD-" + date + "-" + uuid; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..ee3d84a77 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + PENDING, + CONFIRMED, + CANCELLED +} 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..47d2395fb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,99 @@ +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.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; + +@Entity +@Table(name = "products") +@Getter +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false, length = 200) + private String name; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "stock", nullable = false) + private Integer stock; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Column(name = "image_url", length = 500) + private String imageUrl; + + @Version + @Column(name = "version") + private Integer version; + + @Column(name = "likes_count", nullable = false) + private Integer likesCount = 0; + + protected Product() {} + + public Product(Long brandId, String name, Integer price, Integer stock, String description, String imageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + if (price == null || price <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0보다 커야 합니다."); + } + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + this.brandId = brandId; + this.name = name; + this.price = price; + this.stock = stock; + this.description = description; + this.imageUrl = imageUrl; + } + + public void decreaseStock(int quantity) { + if (this.stock < quantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + this.stock -= quantity; + } + + public void increaseStock(int quantity) { + this.stock += quantity; + } + + public void increaseLikes() { + this.likesCount++; + } + + public void decreaseLikes() { + if (this.likesCount > 0) { + this.likesCount--; + } + } + + public void update(String name, Integer price, Integer stock, String description, String imageUrl) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + if (price == null || price <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0보다 커야 합니다."); + } + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + this.name = name; + this.price = price; + this.stock = stock; + this.description = description; + this.imageUrl = imageUrl; + } +} 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..ade3640bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ProductRepository { + Optional findById(Long id); + Page findProducts(Long brandId, Pageable pageable); + Product save(Product product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..d07c2eba2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,48 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public Product createProduct(Long brandId, String name, Integer price, Integer stock, + String description, String imageUrl) { + return productRepository.save(new Product(brandId, name, price, stock, description, imageUrl)); + } + + @Transactional(readOnly = true) + public Product getProduct(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getProducts(Long brandId, Pageable pageable) { + return productRepository.findProducts(brandId, pageable); + } + + @Transactional + public Product updateProduct(Long id, String name, Integer price, Integer stock, + String description, String imageUrl) { + Product product = getProduct(id); + product.update(name, price, stock, description, imageUrl); + return productRepository.save(product); + } + + @Transactional + public void deleteProduct(Long id) { + Product product = getProduct(id); + product.delete(); + productRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 6d7937fb3..346f71626 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -32,6 +32,9 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false, length = 100) private String email; + @Column(name = "balance", nullable = false) + private Long balance = 0L; + protected User() {} public User(String loginId, String encryptedPassword, String name, String birthday, String email) { @@ -42,6 +45,17 @@ public User(String loginId, String encryptedPassword, String name, String birthd this.email = email; } + public void deductBalance(Long amount) { + if (this.balance < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "잔액이 부족합니다."); + } + this.balance -= amount; + } + + public void restoreBalance(Long amount) { + this.balance += amount; + } + public String getMaskedName() { if (name.length() <= 1) { return "*"; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index bc76ebd6c..62773c5b5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -4,5 +4,6 @@ public interface UserRepository { Optional findByLoginId(String loginId); + Optional findById(Long id); User save(User user); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 804e42a06..b43161081 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -30,6 +30,12 @@ public User signUp(String loginId, String rawPassword, String name, String birth @Transactional(readOnly = true) public UserInfo getMyInfo(String loginId, String rawPassword) { + User user = authenticate(loginId, rawPassword); + return UserInfo.fromWithMaskedName(user); + } + + @Transactional(readOnly = true) + public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); @@ -37,6 +43,6 @@ public UserInfo getMyInfo(String loginId, String rawPassword) { throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다."); } - return UserInfo.fromWithMaskedName(user); + return user; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java new file mode 100644 index 000000000..2cc4b7bf9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + Optional findByNameAndDeletedAtIsNull(String name); + Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByDeletedAtIsNull(); + List findAllByIdInAndDeletedAtIsNull(Collection ids); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java new file mode 100644 index 000000000..f0b63b006 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Optional findByName(String name) { + return brandJpaRepository.findByNameAndDeletedAtIsNull(name); + } + + @Override + public List findAll() { + return brandJpaRepository.findAllByDeletedAtIsNull(); + } + + @Override + public List findAllByIds(Collection ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } +} 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..43f382082 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java new file mode 100644 index 000000000..d0cfba0b4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..575d16975 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findByOrderId(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..bd29c4611 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List findByOrderId(Long orderId) { + return orderItemJpaRepository.findByOrderId(orderId); + } + + @Override + public OrderItem save(OrderItem orderItem) { + return orderItemJpaRepository.save(orderItem); + } +} 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..1402eb26f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + Optional findById(Long id); + + Page findByUserId(Long userId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..af55e3c2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public Page findByUserId(Long userId, Pageable pageable) { + return orderJpaRepository.findByUserId(userId, pageable); + } + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } +} 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..263045bad --- /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.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..2a961485f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public Page findProducts(Long brandId, Pageable pageable) { + if (brandId != null) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java new file mode 100644 index 000000000..d89ee854b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByLoginId(String loginId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java new file mode 100644 index 000000000..8cb4c2810 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public Optional findByLoginId(String loginId) { + return userJpaRepository.findByLoginId(loginId); + } + + @Override + public Optional findById(Long id) { + return userJpaRepository.findById(id); + } + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..e55b9c69c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller { + + private final BrandFacade brandFacade; + + @PostMapping + public ApiResponse register( + @RequestBody BrandV1Dto.CreateRequest request + ) { + BrandInfo info = brandFacade.register(request.name(), request.description()); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } + + @GetMapping + public ApiResponse> getBrands() { + List infos = brandFacade.getBrands(); + return ApiResponse.success(infos.stream().map(BrandV1Dto.BrandResponse::from).toList()); + } + + @GetMapping("/{brandId}") + public ApiResponse getBrand(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getBrand(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + public ApiResponse deleteBrand(@PathVariable Long brandId) { + brandFacade.deleteBrand(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..63e88f9a7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record CreateRequest(String name, String description) { + } + + public record BrandResponse(Long id, String name, String description) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.description()); + } + } +} 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..8a1fa0c51 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/likes") +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping + public ApiResponse addLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @RequestBody LikeV1Dto.AddLikeRequest request + ) { + likeFacade.addLike(loginId, rawPassword, request.productId()); + return ApiResponse.success(null); + } + + @DeleteMapping("/{productId}") + public ApiResponse removeLike( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long productId + ) { + likeFacade.removeLike(loginId, rawPassword, productId); + return ApiResponse.success(null); + } +} 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..8f36eefbc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,8 @@ +package com.loopers.interfaces.api.like; + +public class LikeV1Dto { + + public record AddLikeRequest( + Long productId + ) {} +} 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..22ced0b2c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping + public ApiResponse createOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + OrderInfo info = orderFacade.createOrder(loginId, rawPassword, request.toOrderItemRequests()); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @GetMapping + public ApiResponse> getOrders( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PageableDefault(size = 20) Pageable pageable + ) { + Page infos = orderFacade.getOrders(loginId, rawPassword, pageable); + return ApiResponse.success(infos.map(OrderV1Dto.OrderResponse::from)); + } + + @GetMapping("/{orderId}") + public ApiResponse getOrderDetail( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.getOrderDetail(loginId, rawPassword, orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @DeleteMapping("/{orderId}") + public ApiResponse cancelOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.cancelOrder(loginId, rawPassword, orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } + + @PatchMapping("/{orderId}/approve") + public ApiResponse approveOrder( + @PathVariable Long orderId + ) { + OrderInfo info = orderFacade.approveOrder(orderId); + return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); + } +} 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..740b7c181 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,64 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderItemInfo; +import com.loopers.application.order.OrderRequest; +import com.loopers.domain.order.OrderStatus; + +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + List items + ) { + public record OrderItemRequest( + Long productId, + Integer quantity + ) {} + + public List toOrderItemRequests() { + return items.stream() + .map(i -> new OrderRequest.OrderItemRequest(i.productId(), i.quantity())) + .toList(); + } + } + + public record OrderItemResponse( + Long id, + Long productId, + String productName, + Integer price, + Integer quantity + ) { + public static OrderItemResponse from(OrderItemInfo info) { + return new OrderItemResponse( + info.id(), + info.productId(), + info.productName(), + info.price(), + info.quantity() + ); + } + } + + public record OrderResponse( + Long id, + Long userId, + String orderNumber, + OrderStatus status, + Long totalAmount, + List items + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse( + info.id(), + info.userId(), + info.orderNumber(), + info.status(), + info.totalAmount(), + info.items().stream().map(OrderItemResponse::from).toList() + ); + } + } +} 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..8fda8236a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse createProduct( + @RequestBody ProductV1Dto.CreateRequest request + ) { + ProductInfo info = productFacade.createProduct( + request.brandId(), request.name(), request.price(), + request.stock(), request.description(), request.imageUrl() + ); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @PageableDefault(size = 20) Pageable pageable + ) { + Page infos = productFacade.getProducts(brandId, sort, pageable); + return ApiResponse.success(infos.map(ProductV1Dto.ProductResponse::from)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct(@PathVariable Long productId) { + ProductInfo info = productFacade.getProductDetail(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @PatchMapping("/{productId}") + public ApiResponse updateProduct( + @PathVariable Long productId, + @RequestBody ProductV1Dto.UpdateRequest request + ) { + ProductInfo info = productFacade.updateProduct( + productId, request.name(), request.price(), + request.stock(), request.description(), request.imageUrl() + ); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } + + @DeleteMapping("/{productId}") + public ApiResponse deleteProduct(@PathVariable Long productId) { + productFacade.deleteProduct(productId); + return ApiResponse.success(null); + } +} 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..796e6e0cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,49 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; + +public class ProductV1Dto { + + public record CreateRequest( + Long brandId, + String name, + Integer price, + Integer stock, + String description, + String imageUrl + ) {} + + public record UpdateRequest( + String name, + Integer price, + Integer stock, + String description, + String imageUrl + ) {} + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + Integer price, + Integer stock, + Integer likesCount, + String description, + String imageUrl + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likesCount(), + info.description(), + info.imageUrl() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 4f33c5b42..1db320b01 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -7,9 +7,9 @@ public class UserV1Dto { public record SignUpRequest(String loginId, String password, String name, String birthday, String email) { } - public record UserResponse(String loginId, String name, String birthday, String email) { + public record UserResponse(String loginId, String name, String birthday, String email, Long balance) { public static UserResponse from(UserInfo info) { - return new UserResponse(info.loginId(), info.name(), info.birthday(), info.email()); + return new UserResponse(info.loginId(), info.name(), info.birthday(), info.email(), info.balance()); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java new file mode 100644 index 000000000..15bbccf1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,154 @@ +package com.loopers.domain.brand; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @Mock + private BrandRepository brandRepository; + + private BrandService brandService; + + @BeforeEach + void setUp() { + brandService = new BrandService(brandRepository); + } + + @DisplayName("브랜드 등록") + @Nested + class Register { + + @DisplayName("이미 존재하는 이름이면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenNameAlreadyExists() { + // Arrange + given(brandRepository.findByName("나이키")) + .willReturn(Optional.of(new Brand("나이키", "스포츠 브랜드"))); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> brandService.register("나이키", "또 다른 설명")); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("유효한 정보로 등록하면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenInputIsValid() { + // Arrange + Brand brand = new Brand("나이키", "스포츠 브랜드"); + given(brandRepository.findByName("나이키")).willReturn(Optional.empty()); + given(brandRepository.save(any(Brand.class))).willReturn(brand); + + // Act + Brand result = brandService.register("나이키", "스포츠 브랜드"); + + // Assert + assertThat(result.getName()).isEqualTo("나이키"); + } + } + + @DisplayName("브랜드 단건 조회") + @Nested + class GetBrand { + + @DisplayName("존재하는 ID로 조회하면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenIdExists() { + // Arrange + Brand brand = new Brand("나이키", "스포츠 브랜드"); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + + // Act + Brand result = brandService.getBrand(1L); + + // Assert + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(brandRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> brandService.getBrand(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록 조회") + @Nested + class GetBrands { + + @DisplayName("전체 브랜드 목록을 반환한다.") + @Test + void returnsBrandList() { + // Arrange + given(brandRepository.findAll()) + .willReturn(List.of(new Brand("나이키", ""), new Brand("아디다스", ""))); + + // Act + List result = brandService.getBrands(); + + // Assert + assertThat(result).hasSize(2); + } + } + + @DisplayName("브랜드 삭제") + @Nested + class DeleteBrand { + + @DisplayName("존재하는 ID로 삭제하면, 브랜드가 soft delete 된다.") + @Test + void softDeletesBrand_whenIdExists() { + // Arrange + Brand brand = new Brand("나이키", "스포츠 브랜드"); + given(brandRepository.findById(1L)).willReturn(Optional.of(brand)); + given(brandRepository.save(brand)).willReturn(brand); + + // Act + brandService.deleteBrand(1L); + + // Assert + assertThat(brand.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 ID로 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(brandRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> brandService.deleteBrand(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..5146063b8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,45 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandTest { + + @DisplayName("브랜드 생성") + @Nested + class Create { + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, + () -> new Brand(null, "스포츠 브랜드")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 공백이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + CoreException exception = assertThrows(CoreException.class, + () -> new Brand(" ", "스포츠 브랜드")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 이름으로 생성하면, 브랜드가 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + Brand brand = new Brand("나이키", "스포츠 브랜드"); + + assertThat(brand.getName()).isEqualTo("나이키"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java new file mode 100644 index 000000000..a86c9732c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.like; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @Mock + private LikeRepository likeRepository; + + private LikeService likeService; + + @BeforeEach + void setUp() { + likeService = new LikeService(likeRepository); + } + + @DisplayName("좋아요 추가") + @Nested + class AddLike { + + @DisplayName("이미 좋아요를 눌렀으면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenAlreadyLiked() { + // Arrange + given(likeRepository.findByUserIdAndProductId(1L, 2L)) + .willReturn(Optional.of(new Like(1L, 2L))); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> likeService.addLike(1L, 2L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + + @DisplayName("좋아요를 누르지 않은 상태면, Like를 반환한다.") + @Test + void returnsLike_whenNotYetLiked() { + // Arrange + Like like = new Like(1L, 2L); + given(likeRepository.findByUserIdAndProductId(1L, 2L)).willReturn(Optional.empty()); + given(likeRepository.save(any(Like.class))).willReturn(like); + + // Act + Like result = likeService.addLike(1L, 2L); + + // Assert + assertThat(result.getUserId()).isEqualTo(1L); + assertThat(result.getProductId()).isEqualTo(2L); + } + } + + @DisplayName("좋아요 취소") + @Nested + class RemoveLike { + + @DisplayName("좋아요가 존재하지 않으면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLikeDoesNotExist() { + // Arrange + given(likeRepository.findByUserIdAndProductId(1L, 2L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> likeService.removeLike(1L, 2L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("좋아요가 존재하면, 좋아요를 삭제한다.") + @Test + void deletesLike_whenLikeExists() { + // Arrange + Like like = new Like(1L, 2L); + given(likeRepository.findByUserIdAndProductId(1L, 2L)).willReturn(Optional.of(like)); + + // Act & Assert (no exception thrown) + likeService.removeLike(1L, 2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..121999e68 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,47 @@ +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.junit.jupiter.api.Assertions.assertThrows; + +class LikeTest { + + @DisplayName("좋아요 생성") + @Nested + class Create { + + @DisplayName("유효한 userId, productId로 생성하면, Like가 반환된다.") + @Test + void returnsLike_whenInputIsValid() { + // Act + Like like = new Like(1L, 2L); + + // Assert + assertThat(like.getUserId()).isEqualTo(1L); + assertThat(like.getProductId()).isEqualTo(2L); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, + () -> new Like(null, 2L)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, + () -> new Like(1L, null)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java new file mode 100644 index 000000000..4bca5e7e5 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -0,0 +1,168 @@ +package com.loopers.domain.order; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + @Mock + private OrderItemRepository orderItemRepository; + + private OrderService orderService; + + @BeforeEach + void setUp() { + orderService = new OrderService(orderRepository, orderItemRepository); + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("유효한 정보로 생성하면, 주문을 반환한다.") + @Test + void returnsOrder_whenInputIsValid() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.save(any(Order.class))).willReturn(order); + + // Act + Order result = orderService.createOrder(1L, "ORD-20240101-ABCD1234", 50000L); + + // Assert + assertThat(result.getOrderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING); + } + } + + @DisplayName("주문 단건 조회") + @Nested + class GetOrder { + + @DisplayName("존재하는 ID로 조회하면, 주문을 반환한다.") + @Test + void returnsOrder_whenIdExists() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.findById(1L)).willReturn(Optional.of(order)); + + // Act + Order result = orderService.getOrder(1L); + + // Assert + assertThat(result.getOrderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> orderService.getOrder(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 목록 조회") + @Nested + class GetOrders { + + @DisplayName("userId로 주문 목록을 페이지로 반환한다.") + @Test + void returnsOrderPage_byUserId() { + // Arrange + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of( + new Order(1L, "ORD-20240101-ABCD1234", 50000L) + )); + given(orderRepository.findByUserId(1L, pageable)).willReturn(page); + + // Act + Page result = orderService.getOrders(1L, pageable); + + // Assert + assertThat(result.getContent()).hasSize(1); + } + } + + @DisplayName("주문 취소") + @Nested + class CancelOrder { + + @DisplayName("PENDING 상태의 주문을 취소하면, CANCELLED 상태가 된다.") + @Test + void cancelsOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.findById(1L)).willReturn(Optional.of(order)); + given(orderRepository.save(order)).willReturn(order); + + // Act + Order result = orderService.cancelOrder(1L); + + // Assert + assertThat(result.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("존재하지 않는 ID로 취소하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + // Arrange + given(orderRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> orderService.cancelOrder(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 승인") + @Nested + class ApproveOrder { + + @DisplayName("PENDING 상태의 주문을 승인하면, CONFIRMED 상태가 된다.") + @Test + void approvesOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + given(orderRepository.findById(1L)).willReturn(Optional.of(order)); + given(orderRepository.save(order)).willReturn(order); + + // Act + Order result = orderService.approveOrder(1L); + + // Assert + assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED); + } + } +} 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..f8eb502d4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,100 @@ +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.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("주문 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면, PENDING 상태의 주문이 반환된다.") + @Test + void returnsOrder_withPendingStatus() { + // Act + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + + // Assert + assertThat(order.getUserId()).isEqualTo(1L); + assertThat(order.getOrderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + assertThat(order.getTotalAmount()).isEqualTo(50000L); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); + } + + @DisplayName("totalAmount가 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalAmountIsNotPositive() { + // Act & Assert + CoreException exception = assertThrows(CoreException.class, + () -> new Order(1L, "ORD-20240101-ABCD1234", 0L)); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 승인") + @Nested + class Approve { + + @DisplayName("PENDING 상태의 주문을 승인하면, CONFIRMED 상태가 된다.") + @Test + void confirmsOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + + // Act + order.approve(); + + // Assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED); + } + + @DisplayName("PENDING이 아닌 주문을 승인하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIsNotPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + order.approve(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, order::approve); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("주문 취소") + @Nested + class Cancel { + + @DisplayName("PENDING 상태의 주문을 취소하면, CANCELLED 상태가 된다.") + @Test + void cancelsOrder_whenStatusIsPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + + // Act + order.cancel(); + + // Assert + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("PENDING이 아닌 주문을 취소하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIsNotPending() { + // Arrange + Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); + order.cancel(); + + // Act & Assert + CoreException exception = assertThrows(CoreException.class, order::cancel); + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java new file mode 100644 index 000000000..eef012e75 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,144 @@ +package com.loopers.domain.product; + +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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @Mock + private ProductRepository productRepository; + + private ProductService productService; + + @BeforeEach + void setUp() { + productService = new ProductService(productRepository); + } + + @DisplayName("상품 등록") + @Nested + class CreateProduct { + + @DisplayName("유효한 정보로 등록하면, 상품을 반환한다.") + @Test + void returnsProduct_whenInputIsValid() { + // Arrange + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + given(productRepository.save(any(Product.class))).willReturn(product); + + // Act + Product result = productService.createProduct(1L, "나이키 신발", 10000, 100, "설명", "url"); + + // Assert + assertThat(result.getName()).isEqualTo("나이키 신발"); + } + } + + @DisplayName("상품 단건 조회") + @Nested + class GetProduct { + + @DisplayName("존재하는 ID로 조회하면, 상품을 반환한다.") + @Test + void returnsProduct_whenIdExists() { + // Arrange + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + + // Act + Product result = productService.getProduct(1L); + + // Assert + assertThat(result.getName()).isEqualTo("나이키 신발"); + } + + @DisplayName("존재하지 않는 ID로 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(productRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> productService.getProduct(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록 조회") + @Nested + class GetProducts { + + @DisplayName("상품 목록을 페이지로 반환한다.") + @Test + void returnsProductPage() { + // Arrange + PageRequest pageable = PageRequest.of(0, 20); + Page page = new PageImpl<>(List.of( + new Product(1L, "나이키 신발", 10000, 100, "설명", "url") + )); + given(productRepository.findProducts(null, pageable)).willReturn(page); + + // Act + Page result = productService.getProducts(null, pageable); + + // Assert + assertThat(result.getContent()).hasSize(1); + } + } + + @DisplayName("상품 삭제") + @Nested + class DeleteProduct { + + @DisplayName("존재하는 ID로 삭제하면, 상품이 soft delete 된다.") + @Test + void softDeletesProduct_whenIdExists() { + // Arrange + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + given(productRepository.findById(1L)).willReturn(Optional.of(product)); + given(productRepository.save(product)).willReturn(product); + + // Act + productService.deleteProduct(1L); + + // Assert + assertThat(product.getDeletedAt()).isNotNull(); + } + + @DisplayName("존재하지 않는 ID로 삭제하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenIdDoesNotExist() { + // Arrange + given(productRepository.findById(999L)).willReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> productService.deleteProduct(999L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} 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..9c26915f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,123 @@ +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.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + @DisplayName("상품 생성") + @Nested + class Create { + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + CoreException exception = assertThrows(CoreException.class, + () -> new Product(1L, null, 10000, 100, "설명", "url")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 0 이하이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsZeroOrNegative() { + CoreException exception = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 신발", 0, 100, "설명", "url")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsNegative() { + CoreException exception = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 신발", 10000, -1, "설명", "url")); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("유효한 정보로 생성하면, 상품이 생성된다.") + @Test + void createsProduct_whenInputIsValid() { + Product product = new Product(1L, "나이키 신발", 10000, 100, "설명", "url"); + + assertThat(product.getName()).isEqualTo("나이키 신발"); + assertThat(product.getPrice()).isEqualTo(10000); + assertThat(product.getStock()).isEqualTo(100); + assertThat(product.getLikesCount()).isEqualTo(0); + } + } + + @DisplayName("재고 차감") + @Nested + class DecreaseStock { + + @DisplayName("재고가 충분하면, 차감 후 재고가 감소한다.") + @Test + void decreasesStock_whenStockIsSufficient() { + Product product = new Product(1L, "나이키 신발", 10000, 10, "설명", "url"); + + product.decreaseStock(3); + + assertThat(product.getStock()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsInsufficient() { + Product product = new Product(1L, "나이키 신발", 10000, 2, "설명", "url"); + + CoreException exception = assertThrows(CoreException.class, + () -> product.decreaseStock(5)); + + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고 복구") + @Nested + class IncreaseStock { + + @DisplayName("재고를 복구하면, 재고가 증가한다.") + @Test + void increasesStock() { + Product product = new Product(1L, "나이키 신발", 10000, 5, "설명", "url"); + + product.increaseStock(3); + + assertThat(product.getStock()).isEqualTo(8); + } + } + + @DisplayName("좋아요 수 증감") + @Nested + class LikesCount { + + @DisplayName("좋아요를 등록하면, likesCount가 1 증가한다.") + @Test + void increasesLikesCount() { + Product product = new Product(1L, "나이키 신발", 10000, 10, "설명", "url"); + + product.increaseLikes(); + + assertThat(product.getLikesCount()).isEqualTo(1); + } + + @DisplayName("좋아요를 취소하면, likesCount가 1 감소한다.") + @Test + void decreasesLikesCount() { + Product product = new Product(1L, "나이키 신발", 10000, 10, "설명", "url"); + product.increaseLikes(); + + product.decreaseLikes(); + + assertThat(product.getLikesCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index 06c6a8103..dbe126afd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -194,4 +194,61 @@ void throwsBadRequest_whenPasswordIsWrong() { assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("인증") + @Nested + class Authenticate { + + @DisplayName("loginId와 비밀번호가 일치하면, User를 반환한다.") + @Test + void returnsUser_whenCredentialsMatch() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches(rawPassword, "encrypted")).thenReturn(true); + + // Act + User result = userService.authenticate(loginId, rawPassword); + + // Assert + assertThat(result.getLoginId()).isEqualTo(loginId); + } + + @DisplayName("존재하지 않는 loginId이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLoginIdDoesNotExist() { + // Arrange + when(userRepository.findByLoginId("nouser")).thenReturn(Optional.empty()); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + userService.authenticate("nouser", "Test1234!") + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("비밀번호가 틀리면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsWrong() { + // Arrange + String loginId = "testuser"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + + when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongpw1!", "encrypted")).thenReturn(false); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + userService.authenticate(loginId, "wrongpw1!") + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java index bc6cc5cf2..26f8f2356 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserTest.java @@ -1,10 +1,13 @@ package com.loopers.domain.user; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; class UserTest { @@ -38,4 +41,57 @@ void returnsStar_whenNameIsSingleChar() { assertThat(maskedName).isEqualTo("*"); } } + + @DisplayName("잔액 차감") + @Nested + class DeductBalance { + + @DisplayName("잔액이 충분하면, 차감 후 잔액이 감소한다.") + @Test + void deductsBalance_whenBalanceIsSufficient() { + // Arrange + User user = new User("testuser", "encrypted", "홍길동", "19900101", "test@example.com"); + user.restoreBalance(10000L); + + // Act + user.deductBalance(3000L); + + // Assert + assertThat(user.getBalance()).isEqualTo(7000L); + } + + @DisplayName("잔액이 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBalanceIsInsufficient() { + // Arrange + User user = new User("testuser", "encrypted", "홍길동", "19900101", "test@example.com"); + user.restoreBalance(1000L); + + // Act + CoreException exception = assertThrows(CoreException.class, + () -> user.deductBalance(3000L)); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("잔액 복구") + @Nested + class RestoreBalance { + + @DisplayName("잔액을 복구하면, 잔액이 증가한다.") + @Test + void restoresBalance() { + // Arrange + User user = new User("testuser", "encrypted", "홍길동", "19900101", "test@example.com"); + user.restoreBalance(5000L); + + // Act + user.restoreBalance(3000L); + + // Assert + assertThat(user.getBalance()).isEqualTo(8000L); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java index c4eb1c121..0596e9fa9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java @@ -101,7 +101,7 @@ class GetMyInfo { void returns200WithMaskedUserInfo() throws Exception { // Arrange when(userService.getMyInfo("testuser", "password1!")) - .thenReturn(new UserInfo("testuser", "홍길*", "19900101", "test@example.com")); + .thenReturn(new UserInfo("testuser", "홍길*", "19900101", "test@example.com", 0L)); // Act String json = mockMvc.perform(get("/api/v1/users/me") From a185b6e4f6c7b771fd7f308de58d5b5f79643545 Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Mon, 23 Feb 2026 23:35:45 +0900 Subject: [PATCH 04/13] =?UTF-8?q?docs:=20Round3=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EC=A0=84=EB=9E=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- Round3.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Round3.md b/Round3.md index e69de29bb..84b9ccfa3 100644 --- a/Round3.md +++ b/Round3.md @@ -0,0 +1,47 @@ +## 도메인 & 객체 설계 전략 + +비즈니스 규칙 캡슐화: 도메인 객체(Entity, VO)는 데이터만 가진 구조체가 아니라, 자신의 비즈니스 규칙을 스스로 검증하고 수행해야 합니다. + + +애플리케이션 서비스의 역할: 서로 다른 도메인 객체들을 조합하고 로직을 조정(Orchestration)하여 기능을 완성하는 데 집중하며, 핵심 비즈니스 로직은 도메인으로 위임합니다. + + + +규칙의 위치: 특정 규칙이 여러 서비스에서 중복되어 나타난다면, 해당 규칙은 도메인 객체의 책임일 가능성이 높으므로 도메인 내부로 옮깁니다. + + +의도적인 설계: 각 기능의 책임 소재와 객체 간 결합도에 대해 개발자의 의도를 명확히 반영하여 개발을 진행합니다. + +## 아키텍처 및 패키지 구성 전략 +본 프로젝트는 **레이어드 아키텍처(Layered Architecture)**를 기반으로 하며, **DIP(의존성 역전 원칙)**를 jpa 관점에서 적당히 편리한 만큼만 적용한다. + +패키지 구조 (Layer + Domain) +패키징은 4개의 계층을 최상위에 두고, 그 하위에 도메인별로 구성합니다. + + + +/interfaces/api: Presentation 레이어로 API 컨트롤러와 요청/응답 객체가 위치합니다. + + + +/application/..: Application 레이어로 도메인 레이어를 조합하여 유스케이스 기능을 제공합니다. + + + +/domain/..: Domain 레이어로 도메인 객체(Entity, VO, Domain Service)와 Repository 인터페이스가 위치합니다. + + + +/infrastructure/..: Infrastructure 레이어로 JPA, Redis 등 기술적인 Repository 구현체를 제공합니다. + + +데이터 전달 객체(DTO) 정책 + +DTO 분리: API 계층에서 사용하는 Request/Response DTO와 Application 계층에서 사용하는 DTO를 엄격히 분리하여 작성합니다. + +의존성 및 테스트 전략 +DIP 적용: 의존성 방향은 항상 Domain을 향해야 합니다. Infrastructure 구현체는 Domain에 정의된 인터페이스를 상속합니다. + + +단위 테스트: 핵심 도메인 로직은 외부 의존성이 분리된 상태에서 Fake 또는 Stub을 사용하여 테스트 가능한 구조로 설계하고 검증합니다. ++2 \ No newline at end of file From a89ea33ea8c5a7e2aa976d30067881abb703b509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=ED=98=9C=EC=A7=84?= Date: Tue, 24 Feb 2026 15:26:59 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C,=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EC=97=B0=EC=87=84,=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../application/brand/BrandFacade.java | 6 +- .../loopers/application/like/LikeFacade.java | 24 +++++- .../application/order/OrderFacade.java | 14 +++- .../application/order/OrderItemInfo.java | 4 + .../loopers/domain/like/LikeRepository.java | 3 + .../com/loopers/domain/like/LikeService.java | 9 +++ .../com/loopers/domain/order/OrderItem.java | 11 ++- .../loopers/domain/order/OrderService.java | 4 +- .../domain/product/ProductRepository.java | 3 + .../domain/product/ProductService.java | 13 ++++ .../like/LikeJpaRepository.java | 3 + .../like/LikeRepositoryImpl.java | 6 ++ .../product/ProductJpaRepository.java | 3 + .../product/ProductRepositoryImpl.java | 11 +++ .../interfaces/api/like/LikeV1Controller.java | 13 ++++ .../interfaces/api/like/LikeV1Dto.java | 28 +++++++ .../loopers/domain/like/LikeServiceTest.java | 33 ++++++++ .../com/loopers/domain/order/OrderTest.java | 19 +++++ .../domain/product/ProductServiceTest.java | 76 +++++++++++++++++++ 19 files changed, 276 insertions(+), 7 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index d79a13f70..1bb523161 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -2,6 +2,7 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -12,6 +13,7 @@ public class BrandFacade { private final BrandService brandService; + private final ProductService productService; public BrandInfo register(String name, String description) { Brand brand = brandService.register(name, description); @@ -19,8 +21,7 @@ public BrandInfo register(String name, String description) { } public BrandInfo getBrand(Long id) { - Brand brand = brandService.getBrand(id); - return BrandInfo.from(brand); + return BrandInfo.from(brandService.getBrand(id)); } public List getBrands() { @@ -30,6 +31,7 @@ public List getBrands() { } public void deleteBrand(Long id) { + productService.deleteProductsByBrandId(id); brandService.deleteBrand(id); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 91ba933c8..a635074ba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -1,6 +1,8 @@ package com.loopers.application.like; -import com.loopers.domain.like.Like; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; @@ -10,6 +12,10 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class LikeFacade { @@ -17,6 +23,22 @@ public class LikeFacade { private final LikeService likeService; private final ProductService productService; private final UserService userService; + private final BrandService brandService; + + @Transactional(readOnly = true) + public List getLikedProducts(String loginId, String rawPassword) { + User user = userService.authenticate(loginId, rawPassword); + List productIds = likeService.getLikedProductIds(user.getId()); + // 상품 목록을 IN 쿼리로 한 번에 조회 (N+1 방지) + List products = productService.getProductsByIds(productIds); + // 브랜드도 IN 쿼리로 한 번에 조회 후 Map으로 변환 + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + return products.stream() + .map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId()))) + .toList(); + } @Transactional public void addLike(String loginId, String rawPassword, Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 4c1c943fc..50002c7ed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -1,5 +1,7 @@ package com.loopers.application.order; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; @@ -15,6 +17,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -23,6 +27,7 @@ public class OrderFacade { private final OrderService orderService; private final ProductService productService; private final UserService userService; + private final BrandService brandService; @Transactional public OrderInfo createOrder(String loginId, String rawPassword, @@ -40,14 +45,21 @@ public OrderInfo createOrder(String loginId, String rawPassword, user.deductBalance(totalAmount); + // 주문 시점의 브랜드명을 스냅샷으로 저장하기 위해 브랜드 정보를 한 번에 조회 (N+1 방지) + List brandIds = products.stream().map(Product::getBrandId).distinct().toList(); + Map brandMap = brandService.getBrandsByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b)); + String orderNumber = orderService.generateOrderNumber(); Order order = orderService.createOrder(user.getId(), orderNumber, totalAmount); + // 상품명, 브랜드명, 이미지 URL, 단가를 스냅샷으로 저장 (이후 상품 정보 변경에도 주문 내역 보존) for (int i = 0; i < items.size(); i++) { Product product = products.get(i); OrderRequest.OrderItemRequest item = items.get(i); + Brand brand = brandMap.get(product.getBrandId()); orderService.createOrderItem(order.getId(), product.getId(), - product.getName(), product.getPrice(), item.quantity()); + product.getName(), brand.getName(), product.getImageUrl(), product.getPrice(), item.quantity()); } List orderItems = orderService.getOrderItems(order.getId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java index 390b3baa3..d0f0cdec2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -6,6 +6,8 @@ public record OrderItemInfo( Long id, Long productId, String productName, + String brandName, + String imageUrl, Integer price, Integer quantity ) { @@ -14,6 +16,8 @@ public static OrderItemInfo from(OrderItem item) { item.getId(), item.getProductId(), item.getProductName(), + item.getBrandName(), + item.getImageUrl(), item.getPrice(), item.getQuantity() ); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java index 32791a1b9..ea4ff98c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -1,11 +1,14 @@ package com.loopers.domain.like; +import java.util.List; import java.util.Optional; public interface LikeRepository { Optional findByUserIdAndProductId(Long userId, Long productId); + List findByUserId(Long userId); + Like save(Like like); void delete(Like like); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 49540b5a4..cc2ffb43a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -6,12 +6,21 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @RequiredArgsConstructor @Component public class LikeService { private final LikeRepository likeRepository; + @Transactional(readOnly = true) + public List getLikedProductIds(Long userId) { + return likeRepository.findByUserId(userId).stream() + .map(Like::getProductId) + .toList(); + } + @Transactional public Like addLike(Long userId, Long productId) { likeRepository.findByUserIdAndProductId(userId, productId).ifPresent(l -> { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 58180d4fb..db17586fc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -20,6 +20,12 @@ public class OrderItem extends BaseEntity { @Column(name = "product_name", nullable = false, length = 200) private String productName; + @Column(name = "brand_name", length = 100) + private String brandName; + + @Column(name = "image_url", length = 500) + private String imageUrl; + @Column(name = "price", nullable = false) private Integer price; @@ -28,10 +34,13 @@ public class OrderItem extends BaseEntity { protected OrderItem() {} - public OrderItem(Long orderId, Long productId, String productName, Integer price, Integer quantity) { + public OrderItem(Long orderId, Long productId, String productName, String brandName, + String imageUrl, Integer price, Integer quantity) { this.orderId = orderId; this.productId = productId; this.productName = productName; + this.brandName = brandName; + this.imageUrl = imageUrl; this.price = price; this.quantity = quantity; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index ebfa9599e..e36264746 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -24,8 +24,8 @@ public Order createOrder(Long userId, String orderNumber, Long totalAmount) { @Transactional public OrderItem createOrderItem(Long orderId, Long productId, String productName, - Integer price, Integer quantity) { - return orderItemRepository.save(new OrderItem(orderId, productId, productName, price, quantity)); + String brandName, String imageUrl, Integer price, Integer quantity) { + return orderItemRepository.save(new OrderItem(orderId, productId, productName, brandName, imageUrl, price, quantity)); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index ade3640bd..44c89128e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -3,10 +3,13 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface ProductRepository { Optional findById(Long id); Page findProducts(Long brandId, Pageable pageable); + List findAllByBrandId(Long brandId); + List findAllByIds(List ids); Product save(Product product); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index d07c2eba2..57a31ea09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -26,6 +26,11 @@ public Product getProduct(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } + @Transactional(readOnly = true) + public List getProductsByIds(List ids) { + return productRepository.findAllByIds(ids); + } + @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable pageable) { return productRepository.findProducts(brandId, pageable); @@ -45,4 +50,12 @@ public void deleteProduct(Long id) { product.delete(); productRepository.save(product); } + + @Transactional + public void deleteProductsByBrandId(Long brandId) { + productRepository.findAllByBrandId(brandId).forEach(product -> { + product.delete(); + productRepository.save(product); + }); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index 43f382082..4e54fc889 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -3,9 +3,12 @@ import com.loopers.domain.like.Like; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface LikeJpaRepository extends JpaRepository { Optional findByUserIdAndProductId(Long userId, Long productId); + + List findByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index d0cfba0b4..3bf31108d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -18,6 +19,11 @@ public Optional findByUserIdAndProductId(Long userId, Long productId) { return likeJpaRepository.findByUserIdAndProductId(userId, productId); } + @Override + public List findByUserId(Long userId) { + return likeJpaRepository.findByUserId(userId); + } + @Override public Like save(Like like) { return likeJpaRepository.save(like); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 263045bad..b314f2f25 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -5,10 +5,13 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface ProductJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); Page findAllByDeletedAtIsNull(Pageable pageable); Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 2a961485f..76dbeb278 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -28,6 +29,16 @@ public Page findProducts(Long brandId, Pageable pageable) { return productJpaRepository.findAllByDeletedAtIsNull(pageable); } + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId); + } + + @Override + public List findAllByIds(List ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + @Override public Product save(Product product) { return productJpaRepository.save(product); 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 index 8a1fa0c51..a3c91f544 100644 --- 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 @@ -1,9 +1,11 @@ package com.loopers.interfaces.api.like; import com.loopers.application.like.LikeFacade; +import com.loopers.application.product.ProductInfo; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -11,6 +13,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/likes") @@ -18,6 +22,15 @@ public class LikeV1Controller { private final LikeFacade likeFacade; + @GetMapping + public ApiResponse> getLikedProducts( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword + ) { + List products = likeFacade.getLikedProducts(loginId, rawPassword); + return ApiResponse.success(products.stream().map(LikeV1Dto.LikedProductResponse::from).toList()); + } + @PostMapping public ApiResponse addLike( @RequestHeader("X-Loopers-LoginId") String loginId, 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 index 8f36eefbc..2d1ad6755 100644 --- 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 @@ -1,8 +1,36 @@ package com.loopers.interfaces.api.like; +import com.loopers.application.product.ProductInfo; + public class LikeV1Dto { public record AddLikeRequest( Long productId ) {} + + public record LikedProductResponse( + Long id, + Long brandId, + String brandName, + String name, + Integer price, + Integer stock, + Integer likesCount, + String description, + String imageUrl + ) { + public static LikedProductResponse from(ProductInfo info) { + return new LikedProductResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likesCount(), + info.description(), + info.imageUrl() + ); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index a86c9732c..a341bfdab 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -30,6 +31,38 @@ void setUp() { likeService = new LikeService(likeRepository); } + @DisplayName("좋아요한 상품 ID 목록 조회") + @Nested + class GetLikedProductIds { + + @DisplayName("좋아요 목록이 있으면, productId 목록을 반환한다.") + @Test + void returnsProductIds_whenLikesExist() { + // Arrange + given(likeRepository.findByUserId(1L)) + .willReturn(List.of(new Like(1L, 2L), new Like(1L, 3L))); + + // Act + List result = likeService.getLikedProductIds(1L); + + // Assert + assertThat(result).containsExactly(2L, 3L); + } + + @DisplayName("좋아요 목록이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoLikesExist() { + // Arrange + given(likeRepository.findByUserId(1L)).willReturn(List.of()); + + // Act + List result = likeService.getLikedProductIds(1L); + + // Assert + assertThat(result).isEmpty(); + } + } + @DisplayName("좋아요 추가") @Nested class AddLike { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index f8eb502d4..bcd82260a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -11,6 +11,25 @@ class OrderTest { + @DisplayName("주문 항목 생성") + @Nested + class CreateOrderItem { + + @DisplayName("유효한 정보로 생성하면, 스냅샷 정보가 저장된다.") + @Test + void savesSnapshot_whenInputIsValid() { + // Act + OrderItem item = new OrderItem(1L, 2L, "나이키 신발", "나이키", "url", 10000, 3); + + // Assert + assertThat(item.getProductName()).isEqualTo("나이키 신발"); + assertThat(item.getBrandName()).isEqualTo("나이키"); + assertThat(item.getImageUrl()).isEqualTo("url"); + assertThat(item.getPrice()).isEqualTo(10000); + assertThat(item.getQuantity()).isEqualTo(3); + } + } + @DisplayName("주문 생성") @Nested class Create { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java index eef012e75..685ba36a7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -16,6 +16,9 @@ import java.util.List; import java.util.Optional; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; @@ -108,6 +111,79 @@ void returnsProductPage() { } } + @DisplayName("상품 ID 목록 조회") + @Nested + class GetProductsByIds { + + @DisplayName("ID 목록으로 조회하면, 해당 상품 목록을 반환한다.") + @Test + void returnsProducts_whenIdsExist() { + // Arrange + List ids = List.of(1L, 2L); + List products = List.of( + new Product(1L, "나이키 신발", 10000, 100, "설명", "url1"), + new Product(1L, "아디다스 티셔츠", 20000, 50, "설명", "url2") + ); + given(productRepository.findAllByIds(ids)).willReturn(products); + + // Act + List result = productService.getProductsByIds(ids); + + // Assert + assertThat(result).hasSize(2); + } + + @DisplayName("해당하는 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoProductsFound() { + // Arrange + List ids = List.of(999L); + given(productRepository.findAllByIds(ids)).willReturn(List.of()); + + // Act + List result = productService.getProductsByIds(ids); + + // Assert + assertThat(result).isEmpty(); + } + } + + @DisplayName("브랜드별 상품 일괄 삭제") + @Nested + class DeleteProductsByBrandId { + + @DisplayName("브랜드에 속한 상품이 있으면, 모두 soft delete 된다.") + @Test + void softDeletesAllProducts_whenBrandHasProducts() { + // Arrange + Product product1 = new Product(1L, "나이키 신발", 10000, 100, "설명", "url1"); + Product product2 = new Product(1L, "나이키 모자", 5000, 50, "설명", "url2"); + given(productRepository.findAllByBrandId(1L)).willReturn(List.of(product1, product2)); + given(productRepository.save(any(Product.class))).willAnswer(i -> i.getArgument(0)); + + // Act + productService.deleteProductsByBrandId(1L); + + // Assert + assertThat(product1.getDeletedAt()).isNotNull(); + assertThat(product2.getDeletedAt()).isNotNull(); + verify(productRepository, times(2)).save(any(Product.class)); + } + + @DisplayName("브랜드에 속한 상품이 없으면, 아무것도 삭제하지 않는다.") + @Test + void doesNothing_whenBrandHasNoProducts() { + // Arrange + given(productRepository.findAllByBrandId(999L)).willReturn(List.of()); + + // Act + productService.deleteProductsByBrandId(999L); + + // Assert + verify(productRepository, times(0)).save(any(Product.class)); + } + } + @DisplayName("상품 삭제") @Nested class DeleteProduct { From 2452824cf027c9cd7855d64e720214d98364f535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=ED=98=9C=EC=A7=84?= Date: Tue, 24 Feb 2026 16:41:22 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20ProductService=20List=20import=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/loopers/domain/product/ProductService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 57a31ea09..8aed757e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @RequiredArgsConstructor @Component public class ProductService { From f535d971d80fba7cacd42b649039a6a4bf1b1b22 Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Wed, 25 Feb 2026 21:21:20 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20BrandFacade=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EB=88=84=EB=9D=BD=20=EB=B0=8F=20OrderFaca?= =?UTF-8?q?de=20=EC=A3=BC=EB=AC=B8=20=EC=86=8C=EC=9C=A0=EC=9E=90=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrandFacade.deleteBrand에 @Transactional 추가 (연쇄 삭제 원자성 보장) - OrderFacade.cancelOrder에 주문 소유자 검증 추가 (타인 주문 취소 시 NOT_FOUND) Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/loopers/application/brand/BrandFacade.java | 2 ++ .../java/com/loopers/application/order/OrderFacade.java | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 1bb523161..ae3ce8551 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -5,6 +5,7 @@ import com.loopers.domain.product.ProductService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -30,6 +31,7 @@ public List getBrands() { .toList(); } + @Transactional public void deleteBrand(Long id) { productService.deleteProductsByBrandId(id); brandService.deleteBrand(id); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 50002c7ed..4c3e02486 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -2,6 +2,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.domain.order.Order; import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderService; @@ -87,6 +89,10 @@ public OrderInfo getOrderDetail(String loginId, String rawPassword, Long orderId @Transactional public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) { User user = userService.authenticate(loginId, rawPassword); + Order found = orderService.getOrder(orderId); + if (!found.getUserId().equals(user.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } Order order = orderService.cancelOrder(orderId); List orderItems = orderService.getOrderItems(orderId); From 73514b1ed14aeefb006592d6eb0b26b6b715d20a Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Wed, 25 Feb 2026 21:37:05 +0900 Subject: [PATCH 08/13] =?UTF-8?q?refactor:=20OrderService.cancelOrder=20?= =?UTF-8?q?=EC=8B=9C=EA=B7=B8=EB=8B=88=EC=B2=98=EB=A5=BC=20Order=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EB=A5=BC=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cancelOrder(Long id) → cancelOrder(Order order)로 변경하여 중복 조회 제거 - Facade에서 조회한 Order를 직접 전달하도록 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../loopers/application/order/OrderFacade.java | 2 +- .../com/loopers/domain/order/OrderService.java | 3 +-- .../loopers/domain/order/OrderServiceTest.java | 17 +---------------- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 4c3e02486..35e1af551 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -93,7 +93,7 @@ public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) { if (!found.getUserId().equals(user.getId())) { throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); } - Order order = orderService.cancelOrder(orderId); + Order order = orderService.cancelOrder(found); List orderItems = orderService.getOrderItems(orderId); for (OrderItem item : orderItems) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index e36264746..e997598bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -45,8 +45,7 @@ public List getOrderItems(Long orderId) { } @Transactional - public Order cancelOrder(Long id) { - Order order = getOrder(id); + public Order cancelOrder(Order order) { order.cancel(); return orderRepository.save(order); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 4bca5e7e5..31483d981 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -121,29 +121,14 @@ class CancelOrder { void cancelsOrder_whenStatusIsPending() { // Arrange Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); - given(orderRepository.findById(1L)).willReturn(Optional.of(order)); given(orderRepository.save(order)).willReturn(order); // Act - Order result = orderService.cancelOrder(1L); + Order result = orderService.cancelOrder(order); // Assert assertThat(result.getStatus()).isEqualTo(OrderStatus.CANCELLED); } - - @DisplayName("존재하지 않는 ID로 취소하면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsNotFound_whenOrderDoesNotExist() { - // Arrange - given(orderRepository.findById(999L)).willReturn(Optional.empty()); - - // Act - CoreException exception = assertThrows(CoreException.class, - () -> orderService.cancelOrder(999L)); - - // Assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } } @DisplayName("주문 승인") From 237b1d474c9f3c04fc721fbe97bc1f49149a4f72 Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Wed, 25 Feb 2026 22:38:38 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20assertThat=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserServiceTest: isNotNull() 중복 제거 (isEqualTo로 충분) - OrderTest: 테스트 이름과 무관한 필드 검증 제거 (status만 검증) Co-Authored-By: Claude Sonnet 4.6 --- .../src/test/java/com/loopers/domain/order/OrderTest.java | 3 --- .../src/test/java/com/loopers/domain/user/UserServiceTest.java | 1 - 2 files changed, 4 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index bcd82260a..fee7b5bef 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -41,9 +41,6 @@ void returnsOrder_withPendingStatus() { Order order = new Order(1L, "ORD-20240101-ABCD1234", 50000L); // Assert - assertThat(order.getUserId()).isEqualTo(1L); - assertThat(order.getOrderNumber()).isEqualTo("ORD-20240101-ABCD1234"); - assertThat(order.getTotalAmount()).isEqualTo(50000L); assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index dbe126afd..b3b0df25d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -132,7 +132,6 @@ void savesUser_whenInputIsValid() { User result = userService.signUp(loginId, rawPassword, "홍길동", "19900101", "test@example.com"); // Assert - assertThat(result).isNotNull(); assertThat(result.getLoginId()).isEqualTo("newuser"); } } From 789f1159236eb01d720f43812f937606a78fd98a Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Wed, 25 Feb 2026 22:49:40 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat:=20UserFacade=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=9C=84=EB=B0=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserFacade 생성: getMyInfo 유스케이스를 application 레이어로 이동 - UserService에서 getMyInfo 제거 및 UserInfo import 삭제 (domain → application 역방향 의존 해소) - UserV1Controller가 getMyInfo를 UserFacade를 통해 호출하도록 수정 - UserFacadeTest 추가, UserServiceTest의 GetMyInfo 테스트 이동 - UserV1ControllerStandaloneTest를 UserFacade mock 기반으로 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../loopers/application/user/UserFacade.java | 20 +++++ .../com/loopers/domain/user/UserService.java | 7 -- .../interfaces/api/user/UserV1Controller.java | 4 +- .../application/user/UserFacadeTest.java | 85 +++++++++++++++++++ .../loopers/domain/user/UserServiceTest.java | 60 ------------- .../user/UserV1ControllerStandaloneTest.java | 11 ++- 6 files changed, 115 insertions(+), 72 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java new file mode 100644 index 000000000..aaf94f042 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,20 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class UserFacade { + + private final UserService userService; + + @Transactional(readOnly = true) + public UserInfo getMyInfo(String loginId, String rawPassword) { + User user = userService.authenticate(loginId, rawPassword); + return UserInfo.fromWithMaskedName(user); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index b43161081..8af671664 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -1,6 +1,5 @@ package com.loopers.domain.user; -import com.loopers.application.user.UserInfo; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -28,12 +27,6 @@ public User signUp(String loginId, String rawPassword, String name, String birth return userRepository.save(user); } - @Transactional(readOnly = true) - public UserInfo getMyInfo(String loginId, String rawPassword) { - User user = authenticate(loginId, rawPassword); - return UserInfo.fromWithMaskedName(user); - } - @Transactional(readOnly = true) public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 9f4a0ec62..b967734a1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; @@ -18,6 +19,7 @@ public class UserV1Controller { private final UserService userService; + private final UserFacade userFacade; @PostMapping public ApiResponse signUp( @@ -39,7 +41,7 @@ public ApiResponse getMyInfo( @RequestHeader("X-Loopers-LoginId") String loginId, @RequestHeader("X-Loopers-LoginPw") String loginPw ) { - UserInfo info = userService.getMyInfo(loginId, loginPw); + UserInfo info = userFacade.getMyInfo(loginId, loginPw); return ApiResponse.success(UserV1Dto.UserResponse.from(info)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java new file mode 100644 index 000000000..a8af548ee --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserFacadeTest.java @@ -0,0 +1,85 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class UserFacadeTest { + + @Mock + private UserService userService; + + private UserFacade userFacade; + + @BeforeEach + void setUp() { + userFacade = new UserFacade(userService); + } + + @DisplayName("내정보 조회") + @Nested + class GetMyInfo { + + @DisplayName("인증에 성공하면, 마스킹된 회원 정보를 반환한다.") + @Test + void returnsMaskedUserInfo_whenAuthenticated() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + + // Act + UserInfo result = userFacade.getMyInfo(loginId, rawPassword); + + // Assert + assertThat(result.loginId()).isEqualTo("testuser"); + assertThat(result.name()).isEqualTo("홍길*"); + } + + @DisplayName("존재하지 않는 loginId이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLoginIdDoesNotExist() { + // Arrange + given(userService.authenticate("nouser", "Test1234!")) + .willThrow(new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + userFacade.getMyInfo("nouser", "Test1234!") + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("비밀번호가 틀리면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPasswordIsWrong() { + // Arrange + given(userService.authenticate("testuser", "wrongpw1!")) + .willThrow(new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다.")); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + userFacade.getMyInfo("testuser", "wrongpw1!") + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java index b3b0df25d..d658ad68e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceTest.java @@ -8,8 +8,6 @@ import org.junit.jupiter.api.Test; import org.springframework.security.crypto.password.PasswordEncoder; -import com.loopers.application.user.UserInfo; - import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -136,64 +134,6 @@ void savesUser_whenInputIsValid() { } } - @DisplayName("내정보 조회") - @Nested - class GetMyInfo { - - @DisplayName("인증에 성공하면, 마스킹된 회원 정보를 반환한다.") - @Test - void returnsMaskedUserInfo_whenAuthenticated() { - // Arrange - String loginId = "testuser"; - String rawPassword = "Test1234!"; - User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); - - when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches(rawPassword, "encrypted")).thenReturn(true); - - // Act - UserInfo result = userService.getMyInfo(loginId, rawPassword); - - // Assert - assertThat(result.loginId()).isEqualTo("testuser"); - assertThat(result.name()).isEqualTo("홍길*"); - } - - @DisplayName("존재하지 않는 loginId이면, NOT_FOUND 예외가 발생한다.") - @Test - void throwsNotFound_whenLoginIdDoesNotExist() { - // Arrange - when(userRepository.findByLoginId("nouser")).thenReturn(Optional.empty()); - - // Act - CoreException exception = assertThrows(CoreException.class, () -> - userService.getMyInfo("nouser", "Test1234!") - ); - - // Assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - } - - @DisplayName("비밀번호가 틀리면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenPasswordIsWrong() { - // Arrange - String loginId = "testuser"; - User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); - - when(userRepository.findByLoginId(loginId)).thenReturn(Optional.of(user)); - when(passwordEncoder.matches("wrongpw1!", "encrypted")).thenReturn(false); - - // Act - CoreException exception = assertThrows(CoreException.class, () -> - userService.getMyInfo(loginId, "wrongpw1!") - ); - - // Assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } - } - @DisplayName("인증") @Nested class Authenticate { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java index 0596e9fa9..e6da2341d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserV1ControllerStandaloneTest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; import com.loopers.domain.user.UserService; import com.loopers.interfaces.api.ApiControllerAdvice; @@ -31,12 +32,14 @@ class UserV1ControllerStandaloneTest { private MockMvc mockMvc; private UserService userService; + private UserFacade userFacade; private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { userService = mock(UserService.class); - UserV1Controller controller = new UserV1Controller(userService); + userFacade = mock(UserFacade.class); + UserV1Controller controller = new UserV1Controller(userService, userFacade); mockMvc = MockMvcBuilders.standaloneSetup(controller) .setControllerAdvice(new ApiControllerAdvice()) @@ -100,7 +103,7 @@ class GetMyInfo { @Test void returns200WithMaskedUserInfo() throws Exception { // Arrange - when(userService.getMyInfo("testuser", "password1!")) + when(userFacade.getMyInfo("testuser", "password1!")) .thenReturn(new UserInfo("testuser", "홍길*", "19900101", "test@example.com", 0L)); // Act @@ -127,7 +130,7 @@ void returns200WithMaskedUserInfo() throws Exception { @Test void returns404_whenUserNotFound() throws Exception { // Arrange - when(userService.getMyInfo("nouser", "password1!")) + when(userFacade.getMyInfo("nouser", "password1!")) .thenThrow(new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); // Act & Assert @@ -141,7 +144,7 @@ void returns404_whenUserNotFound() throws Exception { @Test void returns400_whenPasswordIsWrong() throws Exception { // Arrange - when(userService.getMyInfo("testuser", "wrongpw1!")) + when(userFacade.getMyInfo("testuser", "wrongpw1!")) .thenThrow(new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다.")); // Act & Assert From 573dedef5eb9090d548f525d44daf20b7cc08bfc Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Wed, 25 Feb 2026 23:11:03 +0900 Subject: [PATCH 11/13] =?UTF-8?q?refactor:=20@Transactional=EC=9D=84=20Ser?= =?UTF-8?q?vice=EC=97=90=EC=84=9C=20Facade=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service 레이어(User/Brand/Product/Like/Order/Member)의 @Transactional 전부 제거 - BrandFacade: register(@Transactional), getBrand/getBrands(@Transactional readOnly) 추가 - ProductFacade: 모든 메서드에 @Transactional 추가 - 트랜잭션 경계를 유스케이스 단위인 Facade로 일원화 Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/loopers/application/brand/BrandFacade.java | 3 +++ .../com/loopers/application/product/ProductFacade.java | 6 ++++++ .../main/java/com/loopers/domain/brand/BrandService.java | 6 ------ .../main/java/com/loopers/domain/like/LikeService.java | 4 ---- .../java/com/loopers/domain/member/MemberService.java | 5 ----- .../main/java/com/loopers/domain/order/OrderService.java | 8 -------- .../java/com/loopers/domain/product/ProductService.java | 8 -------- .../main/java/com/loopers/domain/user/UserService.java | 3 --- 8 files changed, 9 insertions(+), 34 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index ae3ce8551..b592d7405 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -16,15 +16,18 @@ public class BrandFacade { private final BrandService brandService; private final ProductService productService; + @Transactional public BrandInfo register(String name, String description) { Brand brand = brandService.register(name, description); return BrandInfo.from(brand); } + @Transactional(readOnly = true) public BrandInfo getBrand(Long id) { return BrandInfo.from(brandService.getBrand(id)); } + @Transactional(readOnly = true) public List getBrands() { return brandService.getBrands().stream() .map(BrandInfo::from) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 1c2086187..fdcb21dd1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -10,6 +10,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @@ -22,6 +23,7 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + @Transactional public ProductInfo createProduct(Long brandId, String name, Integer price, Integer stock, String description, String imageUrl) { Brand brand = brandService.getBrand(brandId); @@ -29,12 +31,14 @@ public ProductInfo createProduct(Long brandId, String name, Integer price, Integ return ProductInfo.from(product, brand); } + @Transactional(readOnly = true) public ProductInfo getProductDetail(Long id) { Product product = productService.getProduct(id); Brand brand = brandService.getBrand(product.getBrandId()); return ProductInfo.from(product, brand); } + @Transactional(readOnly = true) public Page getProducts(Long brandId, String sort, Pageable pageable) { Pageable sortedPageable = PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort) @@ -48,6 +52,7 @@ public Page getProducts(Long brandId, String sort, Pageable pageabl return products.map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId()))); } + @Transactional public ProductInfo updateProduct(Long id, String name, Integer price, Integer stock, String description, String imageUrl) { Product product = productService.updateProduct(id, name, price, stock, description, imageUrl); @@ -55,6 +60,7 @@ public ProductInfo updateProduct(Long id, String name, Integer price, Integer st return ProductInfo.from(product, brand); } + @Transactional public void deleteProduct(Long id) { productService.deleteProduct(id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 678fa6ad1..3960a74bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -4,7 +4,6 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.List; @@ -15,7 +14,6 @@ public class BrandService { private final BrandRepository brandRepository; - @Transactional public Brand register(String name, String description) { brandRepository.findByName(name).ifPresent(b -> { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); @@ -23,23 +21,19 @@ public Brand register(String name, String description) { return brandRepository.save(new Brand(name, description)); } - @Transactional(readOnly = true) public Brand getBrand(Long id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); } - @Transactional(readOnly = true) public List getBrands() { return brandRepository.findAll(); } - @Transactional(readOnly = true) public List getBrandsByIds(Collection ids) { return brandRepository.findAllByIds(ids); } - @Transactional public void deleteBrand(Long id) { Brand brand = brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index cc2ffb43a..68dcc02a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -4,7 +4,6 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -14,14 +13,12 @@ public class LikeService { private final LikeRepository likeRepository; - @Transactional(readOnly = true) public List getLikedProductIds(Long userId) { return likeRepository.findByUserId(userId).stream() .map(Like::getProductId) .toList(); } - @Transactional public Like addLike(Long userId, Long productId) { likeRepository.findByUserIdAndProductId(userId, productId).ifPresent(l -> { throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); @@ -29,7 +26,6 @@ public Like addLike(Long userId, Long productId) { return likeRepository.save(new Like(userId, productId)); } - @Transactional public void removeLike(Long userId, Long productId) { Like like = likeRepository.findByUserIdAndProductId(userId, productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요를 찾을 수 없습니다.")); 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 index 37faab83d..f591eea14 100644 --- 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 @@ -6,7 +6,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -15,7 +14,6 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; - @Transactional public Member register(String loginId, String rawPassword, String name, String birthday, String email) { Member.validateRawPassword(rawPassword, birthday); String encryptedPassword = passwordEncoder.encode(rawPassword); @@ -27,19 +25,16 @@ public Member register(String loginId, String rawPassword, String name, String b return memberRepository.save(member); } - @Transactional(readOnly = true) public Member getMember(String loginId) { return memberRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); } - @Transactional(readOnly = true) public MemberInfo getMyInfo(String loginId, String rawPassword) { Member member = authenticate(loginId, rawPassword); return MemberInfo.fromWithMaskedName(member); } - @Transactional public void changePassword(String loginId, String rawCurrentPassword, String rawNewPassword) { Member member = authenticate(loginId, rawCurrentPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index e997598bf..3fc71c7bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -6,7 +6,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -17,40 +16,33 @@ public class OrderService { private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; - @Transactional public Order createOrder(Long userId, String orderNumber, Long totalAmount) { return orderRepository.save(new Order(userId, orderNumber, totalAmount)); } - @Transactional public OrderItem createOrderItem(Long orderId, Long productId, String productName, String brandName, String imageUrl, Integer price, Integer quantity) { return orderItemRepository.save(new OrderItem(orderId, productId, productName, brandName, imageUrl, price, quantity)); } - @Transactional(readOnly = true) public Order getOrder(Long id) { return orderRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); } - @Transactional(readOnly = true) public Page getOrders(Long userId, Pageable pageable) { return orderRepository.findByUserId(userId, pageable); } - @Transactional(readOnly = true) public List getOrderItems(Long orderId) { return orderItemRepository.findByOrderId(orderId); } - @Transactional public Order cancelOrder(Order order) { order.cancel(); return orderRepository.save(order); } - @Transactional public Order approveOrder(Long id) { Order order = getOrder(id); order.approve(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 8aed757e8..720f414b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -6,7 +6,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -16,29 +15,24 @@ public class ProductService { private final ProductRepository productRepository; - @Transactional public Product createProduct(Long brandId, String name, Integer price, Integer stock, String description, String imageUrl) { return productRepository.save(new Product(brandId, name, price, stock, description, imageUrl)); } - @Transactional(readOnly = true) public Product getProduct(Long id) { return productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } - @Transactional(readOnly = true) public List getProductsByIds(List ids) { return productRepository.findAllByIds(ids); } - @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable pageable) { return productRepository.findProducts(brandId, pageable); } - @Transactional public Product updateProduct(Long id, String name, Integer price, Integer stock, String description, String imageUrl) { Product product = getProduct(id); @@ -46,14 +40,12 @@ public Product updateProduct(Long id, String name, Integer price, Integer stock, return productRepository.save(product); } - @Transactional public void deleteProduct(Long id) { Product product = getProduct(id); product.delete(); productRepository.save(product); } - @Transactional public void deleteProductsByBrandId(Long brandId) { productRepository.findAllByBrandId(brandId).forEach(product -> { product.delete(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index 8af671664..a99fd0721 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -5,7 +5,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -14,7 +13,6 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; - @Transactional public User signUp(String loginId, String rawPassword, String name, String birthday, String email) { userRepository.findByLoginId(loginId).ifPresent(u -> { throw new CoreException(ErrorType.CONFLICT, "이미 가입된 로그인 ID입니다."); @@ -27,7 +25,6 @@ public User signUp(String loginId, String rawPassword, String name, String birth return userRepository.save(user); } - @Transactional(readOnly = true) public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); From 9e81b2cf88469788afb6b59105a15b76014ba127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=ED=98=9C=EC=A7=84?= Date: Thu, 26 Feb 2026 11:03:21 +0900 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20@Transactional=EC=9D=84=20Ser?= =?UTF-8?q?vice=EB=A1=9C=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User/Brand/Product/Like/Order/MemberService 메서드에 @Transactional 복원 - 쓰기 메서드: @Transactional, 읽기 메서드: @Transactional(readOnly = true) Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/com/loopers/domain/brand/BrandService.java | 6 ++++++ .../main/java/com/loopers/domain/like/LikeService.java | 4 ++++ .../java/com/loopers/domain/member/MemberService.java | 5 +++++ .../main/java/com/loopers/domain/order/OrderService.java | 8 ++++++++ .../java/com/loopers/domain/product/ProductService.java | 8 ++++++++ .../main/java/com/loopers/domain/user/UserService.java | 3 +++ 6 files changed, 34 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index 3960a74bb..678fa6ad1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -4,6 +4,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.Collection; import java.util.List; @@ -14,6 +15,7 @@ public class BrandService { private final BrandRepository brandRepository; + @Transactional public Brand register(String name, String description) { brandRepository.findByName(name).ifPresent(b -> { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); @@ -21,19 +23,23 @@ public Brand register(String name, String description) { return brandRepository.save(new Brand(name, description)); } + @Transactional(readOnly = true) public Brand getBrand(Long id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); } + @Transactional(readOnly = true) public List getBrands() { return brandRepository.findAll(); } + @Transactional(readOnly = true) public List getBrandsByIds(Collection ids) { return brandRepository.findAllByIds(ids); } + @Transactional public void deleteBrand(Long id) { Brand brand = brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java index 68dcc02a8..cc2ffb43a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -4,6 +4,7 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -13,12 +14,14 @@ public class LikeService { private final LikeRepository likeRepository; + @Transactional(readOnly = true) public List getLikedProductIds(Long userId) { return likeRepository.findByUserId(userId).stream() .map(Like::getProductId) .toList(); } + @Transactional public Like addLike(Long userId, Long productId) { likeRepository.findByUserIdAndProductId(userId, productId).ifPresent(l -> { throw new CoreException(ErrorType.CONFLICT, "이미 좋아요를 누른 상품입니다."); @@ -26,6 +29,7 @@ public Like addLike(Long userId, Long productId) { return likeRepository.save(new Like(userId, productId)); } + @Transactional public void removeLike(Long userId, Long productId) { Like like = likeRepository.findByUserIdAndProductId(userId, productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요를 찾을 수 없습니다.")); 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 index f591eea14..37faab83d 100644 --- 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 @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -14,6 +15,7 @@ public class MemberService { private final MemberRepository memberRepository; private final PasswordEncoder passwordEncoder; + @Transactional public Member register(String loginId, String rawPassword, String name, String birthday, String email) { Member.validateRawPassword(rawPassword, birthday); String encryptedPassword = passwordEncoder.encode(rawPassword); @@ -25,16 +27,19 @@ public Member register(String loginId, String rawPassword, String name, String b return memberRepository.save(member); } + @Transactional(readOnly = true) public Member getMember(String loginId) { return memberRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); } + @Transactional(readOnly = true) public MemberInfo getMyInfo(String loginId, String rawPassword) { Member member = authenticate(loginId, rawPassword); return MemberInfo.fromWithMaskedName(member); } + @Transactional public void changePassword(String loginId, String rawCurrentPassword, String rawNewPassword) { Member member = authenticate(loginId, rawCurrentPassword); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java index 3fc71c7bd..e997598bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -16,33 +17,40 @@ public class OrderService { private final OrderRepository orderRepository; private final OrderItemRepository orderItemRepository; + @Transactional public Order createOrder(Long userId, String orderNumber, Long totalAmount) { return orderRepository.save(new Order(userId, orderNumber, totalAmount)); } + @Transactional public OrderItem createOrderItem(Long orderId, Long productId, String productName, String brandName, String imageUrl, Integer price, Integer quantity) { return orderItemRepository.save(new OrderItem(orderId, productId, productName, brandName, imageUrl, price, quantity)); } + @Transactional(readOnly = true) public Order getOrder(Long id) { return orderRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); } + @Transactional(readOnly = true) public Page getOrders(Long userId, Pageable pageable) { return orderRepository.findByUserId(userId, pageable); } + @Transactional(readOnly = true) public List getOrderItems(Long orderId) { return orderItemRepository.findByOrderId(orderId); } + @Transactional public Order cancelOrder(Order order) { order.cancel(); return orderRepository.save(order); } + @Transactional public Order approveOrder(Long id) { Order order = getOrder(id); order.approve(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 720f414b2..8aed757e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -15,24 +16,29 @@ public class ProductService { private final ProductRepository productRepository; + @Transactional public Product createProduct(Long brandId, String name, Integer price, Integer stock, String description, String imageUrl) { return productRepository.save(new Product(brandId, name, price, stock, description, imageUrl)); } + @Transactional(readOnly = true) public Product getProduct(Long id) { return productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } + @Transactional(readOnly = true) public List getProductsByIds(List ids) { return productRepository.findAllByIds(ids); } + @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable pageable) { return productRepository.findProducts(brandId, pageable); } + @Transactional public Product updateProduct(Long id, String name, Integer price, Integer stock, String description, String imageUrl) { Product product = getProduct(id); @@ -40,12 +46,14 @@ public Product updateProduct(Long id, String name, Integer price, Integer stock, return productRepository.save(product); } + @Transactional public void deleteProduct(Long id) { Product product = getProduct(id); product.delete(); productRepository.save(product); } + @Transactional public void deleteProductsByBrandId(Long brandId) { productRepository.findAllByBrandId(brandId).forEach(product -> { product.delete(); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java index a99fd0721..8af671664 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Component @@ -13,6 +14,7 @@ public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + @Transactional public User signUp(String loginId, String rawPassword, String name, String birthday, String email) { userRepository.findByLoginId(loginId).ifPresent(u -> { throw new CoreException(ErrorType.CONFLICT, "이미 가입된 로그인 ID입니다."); @@ -25,6 +27,7 @@ public User signUp(String loginId, String rawPassword, String name, String birth return userRepository.save(user); } + @Transactional(readOnly = true) public User authenticate(String loginId, String rawPassword) { User user = userRepository.findByLoginId(loginId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); From f67c5ab1f574ebc6f192e2cb11337930169d7cb1 Mon Sep 17 00:00:00 2001 From: hyejin cho Date: Fri, 27 Feb 2026 00:29:46 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../application/order/OrderFacade.java | 23 ++- .../application/product/ProductFacade.java | 10 +- .../application/product/ProductInfo.java | 11 ++ .../interfaces/api/like/LikeV1Controller.java | 5 + .../api/order/OrderV1Controller.java | 4 +- .../application/order/OrderFacadeTest.java | 137 ++++++++++++++++++ .../product/ProductFacadeTest.java | 92 ++++++++++++ .../application/product/ProductInfoTest.java | 83 +++++++++++ .../like/LikeV1ControllerStandaloneTest.java | 88 +++++++++++ 9 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 35e1af551..c5a4625ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -72,16 +72,26 @@ public OrderInfo createOrder(String loginId, String rawPassword, public Page getOrders(String loginId, String rawPassword, Pageable pageable) { User user = userService.authenticate(loginId, rawPassword); Page orders = orderService.getOrders(user.getId(), pageable); - return orders.map(order -> { - List items = orderService.getOrderItems(order.getId()); - return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); - }); + + List orderIds = orders.stream().map(Order::getId).toList(); + Map> itemsByOrderId = orderService.getOrderItemsByOrderIds(orderIds).stream() + .collect(Collectors.groupingBy( + OrderItem::getOrderId, + Collectors.mapping(OrderItemInfo::from, Collectors.toList()) + )); + + return orders.map(order -> + OrderInfo.from(order, itemsByOrderId.getOrDefault(order.getId(), List.of())) + ); } @Transactional(readOnly = true) public OrderInfo getOrderDetail(String loginId, String rawPassword, Long orderId) { - userService.authenticate(loginId, rawPassword); + User user = userService.authenticate(loginId, rawPassword); Order order = orderService.getOrder(orderId); + if (!order.getUserId().equals(user.getId())) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } List items = orderService.getOrderItems(orderId); return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); } @@ -107,7 +117,8 @@ public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) { } @Transactional - public OrderInfo approveOrder(Long orderId) { + public OrderInfo approveOrder(String loginId, String rawPassword, Long orderId) { + userService.authenticate(loginId, rawPassword); Order order = orderService.approveOrder(orderId); List items = orderService.getOrderItems(orderId); return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index fdcb21dd1..9e62ea2b3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -4,6 +4,8 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -49,7 +51,13 @@ public Page getProducts(Long brandId, String sort, Pageable pageabl Map brandMap = brandService.getBrandsByIds(brandIds).stream() .collect(Collectors.toMap(Brand::getId, b -> b)); - return products.map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId()))); + return products.map(p -> { + Brand brand = brandMap.get(p.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); + } + return ProductInfo.from(p, brand); + }); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java index a867ef0db..9c1517619 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -2,6 +2,8 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; public record ProductInfo( Long id, @@ -15,6 +17,15 @@ public record ProductInfo( String imageUrl ) { public static ProductInfo from(Product product, Brand brand) { + if (product == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 정보가 없습니다."); + } + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, "브랜드 정보를 찾을 수 없습니다."); + } + if (!product.getBrandId().equals(brand.getId())) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품-브랜드 매핑이 올바르지 않습니다."); + } return new ProductInfo( product.getId(), product.getBrandId(), 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 index a3c91f544..578ad3d86 100644 --- 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 @@ -3,6 +3,8 @@ import com.loopers.application.like.LikeFacade; import com.loopers.application.product.ProductInfo; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -37,6 +39,9 @@ public ApiResponse addLike( @RequestHeader("X-Loopers-LoginPw") String rawPassword, @RequestBody LikeV1Dto.AddLikeRequest request ) { + if (request == null || request.productId() == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "productId는 필수입니다."); + } likeFacade.addLike(loginId, rawPassword, request.productId()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 22ced0b2c..5f83f96bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -66,9 +66,11 @@ public ApiResponse cancelOrder( @PatchMapping("/{orderId}/approve") public ApiResponse approveOrder( + @RequestHeader("X-Loopers-LoginId") String loginId, + @RequestHeader("X-Loopers-LoginPw") String rawPassword, @PathVariable Long orderId ) { - OrderInfo info = orderFacade.approveOrder(orderId); + OrderInfo info = orderFacade.approveOrder(loginId, rawPassword, orderId); return ApiResponse.success(OrderV1Dto.OrderResponse.from(info)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java new file mode 100644 index 000000000..7f19bb338 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -0,0 +1,137 @@ +package com.loopers.application.order; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class OrderFacadeTest { + + @Mock + private OrderService orderService; + + @Mock + private ProductService productService; + + @Mock + private UserService userService; + + @Mock + private BrandService brandService; + + private OrderFacade orderFacade; + + @BeforeEach + void setUp() { + orderFacade = new OrderFacade(orderService, productService, userService, brandService); + } + + @DisplayName("주문 상세 조회") + @Nested + class GetOrderDetail { + + @DisplayName("본인 주문을 조회하면, 주문 정보를 반환한다.") + @Test + void returnsOrderInfo_whenUserOwnsTheOrder() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + // user.getId() == 0L (BaseEntity 기본값), 동일한 userId로 주문 생성 + Order order = new Order(user.getId(), "ORD-20240101-ABCD1234", 50000L); + + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(orderService.getOrder(1L)).willReturn(order); + given(orderService.getOrderItems(1L)).willReturn(List.of()); + + // Act + OrderInfo result = orderFacade.getOrderDetail(loginId, rawPassword, 1L); + + // Assert + assertThat(result.orderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + assertThat(result.totalAmount()).isEqualTo(50000L); + } + + @DisplayName("다른 사용자의 주문을 조회하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenUserDoesNotOwnTheOrder() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + // user.getId() == 0L, 다른 사용자(99L) 소유의 주문 + Order order = new Order(99L, "ORD-20240101-ABCD9999", 50000L); + + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(orderService.getOrder(1L)).willReturn(order); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + orderFacade.getOrderDetail(loginId, rawPassword, 1L) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("주문 승인") + @Nested + class ApproveOrder { + + @DisplayName("인증에 성공하면, 승인된 주문 정보를 반환한다.") + @Test + void returnsApprovedOrderInfo_whenAuthenticated() { + // Arrange + String loginId = "testuser"; + String rawPassword = "Test1234!"; + User user = new User(loginId, "encrypted", "홍길동", "19900101", "test@example.com"); + // user.getId() == 0L (BaseEntity 기본값) + Order order = new Order(0L, "ORD-20240101-ABCD1234", 50000L); + + given(userService.authenticate(loginId, rawPassword)).willReturn(user); + given(orderService.approveOrder(1L)).willReturn(order); + given(orderService.getOrderItems(1L)).willReturn(List.of()); + + // Act + OrderInfo result = orderFacade.approveOrder(loginId, rawPassword, 1L); + + // Assert + assertThat(result.orderNumber()).isEqualTo("ORD-20240101-ABCD1234"); + } + + @DisplayName("인증에 실패하면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenAuthenticationFails() { + // Arrange + given(userService.authenticate("nouser", "Test1234!")) + .willThrow(new CoreException(ErrorType.NOT_FOUND, "회원을 찾을 수 없습니다.")); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + orderFacade.approveOrder("nouser", "Test1234!", 1L) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java new file mode 100644 index 000000000..46a3990d8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -0,0 +1,92 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import 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 org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ProductFacadeTest { + + @Mock + private ProductService productService; + + @Mock + private BrandService brandService; + + private ProductFacade productFacade; + + @BeforeEach + void setUp() { + productFacade = new ProductFacade(productService, brandService); + } + + @DisplayName("상품 목록 조회") + @Nested + class GetProducts { + + @DisplayName("모든 브랜드 정보가 존재하면, 상품 목록을 반환한다.") + @Test + void returnsProductPage_whenAllBrandsExist() { + // Arrange + Long brandId = 1L; + // Brand.getId() == 0L (BaseEntity 기본값), product.getBrandId()를 동일하게 맞춤 + Product product = new Product(0L, "상품A", 10000, 100, "설명", "https://img.url"); + Brand brand = new Brand("브랜드A", "브랜드 설명"); + Page productPage = new PageImpl<>(List.of(product)); + + given(productService.getProducts(eq(brandId), any(Pageable.class))).willReturn(productPage); + given(brandService.getBrandsByIds(any())).willReturn(List.of(brand)); + + // Act + Page result = productFacade.getProducts(brandId, "latest", PageRequest.of(0, 10)); + + // Assert + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getContent().get(0).name()).isEqualTo("상품A"); + assertThat(result.getContent().get(0).brandName()).isEqualTo("브랜드A"); + } + + @DisplayName("일부 상품의 브랜드 정보가 누락되면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandIsMissingForProduct() { + // Arrange + Long brandId = 1L; + // brandId=99L 상품인데, brandService는 해당 브랜드를 반환하지 않음 + Product product = new Product(99L, "고아상품", 5000, 10, "설명", "https://img.url"); + Page productPage = new PageImpl<>(List.of(product)); + + given(productService.getProducts(eq(brandId), any(Pageable.class))).willReturn(productPage); + given(brandService.getBrandsByIds(any())).willReturn(List.of()); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + productFacade.getProducts(brandId, "latest", PageRequest.of(0, 10)) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java new file mode 100644 index 000000000..a22ced20d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductInfoTest.java @@ -0,0 +1,83 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductInfoTest { + + @DisplayName("ProductInfo.from()") + @Nested + class From { + + @DisplayName("유효한 상품과 브랜드로 생성하면, ProductInfo를 반환한다.") + @Test + void returnsProductInfo_whenProductAndBrandAreValid() { + // Arrange + // Brand.getId() == 0L (BaseEntity 기본값), product.getBrandId()를 동일하게 맞춤 + Product product = new Product(0L, "상품A", 10000, 100, "설명", "https://img.url"); + Brand brand = new Brand("브랜드A", "브랜드 설명"); + + // Act + ProductInfo result = ProductInfo.from(product, brand); + + // Assert + assertThat(result.name()).isEqualTo("상품A"); + assertThat(result.brandName()).isEqualTo("브랜드A"); + } + + @DisplayName("product가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIsNull() { + // Arrange + Brand brand = new Brand("브랜드A", "브랜드 설명"); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + ProductInfo.from(null, brand) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("brand가 null이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandIsNull() { + // Arrange + Product product = new Product(1L, "상품A", 10000, 100, "설명", "https://img.url"); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + ProductInfo.from(product, null) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("상품의 brandId와 브랜드의 id가 다르면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdMismatch() { + // Arrange + // product.getBrandId() == 99L, brand.getId() == 0L (BaseEntity 기본값) → 불일치 + Product product = new Product(99L, "상품A", 10000, 100, "설명", "https://img.url"); + Brand brand = new Brand("브랜드A", "브랜드 설명"); + + // Act + CoreException exception = assertThrows(CoreException.class, () -> + ProductInfo.from(product, brand) + ); + + // Assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java new file mode 100644 index 000000000..29b660af6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ControllerStandaloneTest.java @@ -0,0 +1,88 @@ +package com.loopers.interfaces.api.like; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.like.LikeFacade; +import com.loopers.interfaces.api.ApiControllerAdvice; +import com.loopers.interfaces.api.ApiResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LikeV1ControllerStandaloneTest { + + private MockMvc mockMvc; + private LikeFacade likeFacade; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + likeFacade = mock(LikeFacade.class); + LikeV1Controller controller = new LikeV1Controller(likeFacade); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new ApiControllerAdvice()) + .build(); + } + + @DisplayName("좋아요 추가") + @Nested + class AddLike { + + @DisplayName("요청 본문이 없으면, 400을 반환한다.") + @Test + void returns400_whenBodyIsMissing() throws Exception { + // Act & Assert + mockMvc.perform(post("/api/v1/likes") + .header("X-Loopers-LoginId", "testuser") + .header("X-Loopers-LoginPw", "Test1234!") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("productId가 null이면, 400을 반환한다.") + @Test + void returns400_whenProductIdIsNull() throws Exception { + // Act + String json = mockMvc.perform(post("/api/v1/likes") + .header("X-Loopers-LoginId", "testuser") + .header("X-Loopers-LoginPw", "Test1234!") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"productId": null} + """)) + .andExpect(status().isBadRequest()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + + // Assert + ApiResponse response = objectMapper.readValue(json, new TypeReference>() {}); + assertThat(response.meta().result()).isEqualTo(ApiResponse.Metadata.Result.FAIL); + } + + @DisplayName("유효한 요청이면, 200을 반환한다.") + @Test + void returns200_whenRequestIsValid() throws Exception { + // Act & Assert + mockMvc.perform(post("/api/v1/likes") + .header("X-Loopers-LoginId", "testuser") + .header("X-Loopers-LoginPw", "Test1234!") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"productId": 1} + """)) + .andExpect(status().isOk()); + } + } +}