From 0cd9724d00ffbb100a6a9aa744a6ee9700eb59b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 19 Feb 2026 14:51:15 +0900 Subject: [PATCH 01/55] =?UTF-8?q?feat=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B0=9D=EC=B2=B4=20&=20jpa=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/Brand.java | 37 ++++++++++++++++ .../loopers/domain/brand/BrandRepository.java | 6 +++ .../infrastructure/brand/BrandEntity.java | 42 +++++++++++++++++++ .../brand/BrandJpaRepository.java | 10 +++++ .../brand/BrandRepositoryImpl.java | 19 +++++++++ 5 files changed, 114 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.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 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..9cac16f96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,37 @@ +package com.loopers.domain.brand; + +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.ZonedDateTime; + +public record Brand(Long id, String name, String description, ZonedDateTime createdAt) { + + public static Brand create(Long id, String name, String description) { + validateName(name); + + return new Brand(id, name, description, null); + } + + public static Brand toDomain(BrandEntity brandEntity) { + return new Brand(brandEntity.getId(), brandEntity.getName(), brandEntity.getDescription(), brandEntity.getCreatedAt()); + } + + private static void validateName(String name) { + if(name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수 입니다"); + } + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..3967b12d6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.brand; + +public interface BrandRepository { + + Brand save(Brand brand); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java new file mode 100644 index 000000000..bd1b890f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.brand.Brand; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "brand") +@NoArgsConstructor +public class BrandEntity extends BaseEntity { + + @Comment("브랜드 이름") + @Column(name = "name", nullable = false) + private String name; + + @Comment("브랜드 설명") + @Column(name = "description", nullable = true) + private String description; + + private BrandEntity(String name, String description) { + super(); + + this.name = name; + this.description = description; + } + + public static BrandEntity toEntity(Brand brand) { + return new BrandEntity(brand.getName(), brand.getDescription()); + } + + public String getName() { + return this.name; + } + + public String getDescription() { + return this.description; + } +} 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..c28571bec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BrandJpaRepository extends JpaRepository { + + Brand save(Brand brand); + +} 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..fb07f97d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,19 @@ +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; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + BrandEntity brandEntity = BrandEntity.toEntity(brand); + return Brand.toDomain(brandJpaRepository.save(brandEntity)); + } +} From 4bcf4017939d84f674ff2ed4981adf2284893df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 19 Feb 2026 14:51:28 +0900 Subject: [PATCH 02/55] =?UTF-8?q?test=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/brand/BrandTest.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java 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..90a6acde9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,39 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * 브랜드 도메인 테스트 + */ +class BrandTest { + + @DisplayName("브랜드 도메인 생성 성공 테스트") + @Test + void success_create_brand() { + String name = "brand1"; + String description = "description1"; + + Brand brand = Brand.create(null, name, description); + + Assertions.assertThat(brand.getName()).isEqualTo(name); + Assertions.assertThat(brand.getDescription()).isEqualTo(description); + } + + @DisplayName("브랜드 이름이 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_create_brand_with_invalid_name(String name) { + String description = "description1"; + + Assertions.assertThatThrownBy(() -> Brand.create(null, name, description)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드 이름은 필수 입니다"); + } +} From 2a63a2844256be69b58a1e713b7d5c18f23d6f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 19 Feb 2026 16:55:42 +0900 Subject: [PATCH 03/55] =?UTF-8?q?feat=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/Brand.java | 16 ++++++---- .../loopers/domain/brand/BrandRepository.java | 6 +++- .../loopers/domain/brand/BrandService.java | 29 +++++++++++++++++++ .../infrastructure/brand/BrandEntity.java | 13 +++++++-- .../brand/BrandJpaRepository.java | 3 -- .../brand/BrandRepositoryImpl.java | 26 +++++++++++++++-- 6 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index 9cac16f96..cc5ce9c91 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -1,6 +1,5 @@ package com.loopers.domain.brand; -import com.loopers.infrastructure.brand.BrandEntity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.time.ZonedDateTime; @@ -13,10 +12,6 @@ public static Brand create(Long id, String name, String description) { return new Brand(id, name, description, null); } - public static Brand toDomain(BrandEntity brandEntity) { - return new Brand(brandEntity.getId(), brandEntity.getName(), brandEntity.getDescription(), brandEntity.getCreatedAt()); - } - private static void validateName(String name) { if(name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수 입니다"); @@ -34,4 +29,15 @@ public String getName() { public String getDescription() { return description; } + + public Brand update(String name, String description) { + validateName(name); + + return new Brand( + this.id, + name, + description, + this.createdAt + ); + } } 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 index 3967b12d6..69869647e 100644 --- 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 @@ -1,6 +1,10 @@ package com.loopers.domain.brand; +import java.util.Optional; + public interface BrandRepository { - Brand save(Brand brand); + Brand create(Brand brand); + Brand update(Long id, Brand brand); + Optional findById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..660959185 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,29 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + public void create(String name, String description) { + Brand brand = Brand.create(null, name, description); + brandRepository.create(brand); + } + + public void update(Long id, String name, String description) { + Brand existing = findById(id); + Brand updated = existing.update(name, description); + brandRepository.update(id, updated); + } + + public Brand findById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java index bd1b890f8..65f58d6ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -22,16 +22,23 @@ public class BrandEntity extends BaseEntity { private String description; private BrandEntity(String name, String description) { - super(); - this.name = name; this.description = description; } - public static BrandEntity toEntity(Brand brand) { + public static BrandEntity create(Brand brand) { return new BrandEntity(brand.getName(), brand.getDescription()); } + public static Brand toDomain(BrandEntity brandEntity) { + return new Brand(brandEntity.getId(), brandEntity.getName(), brandEntity.getDescription(), brandEntity.getCreatedAt()); + } + + public void update(Brand brand) { + this.name = brand.getName(); + this.description = brand.getDescription(); + } + public String getName() { return this.name; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index c28571bec..566223c34 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -1,10 +1,7 @@ package com.loopers.infrastructure.brand; -import com.loopers.domain.brand.Brand; import org.springframework.data.jpa.repository.JpaRepository; public interface BrandJpaRepository extends JpaRepository { - Brand save(Brand brand); - } 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 index fb07f97d5..c222a386f 100644 --- 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 @@ -2,6 +2,9 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -12,8 +15,25 @@ public class BrandRepositoryImpl implements BrandRepository { private final BrandJpaRepository brandJpaRepository; @Override - public Brand save(Brand brand) { - BrandEntity brandEntity = BrandEntity.toEntity(brand); - return Brand.toDomain(brandJpaRepository.save(brandEntity)); + public Brand create(Brand brand) { + BrandEntity brandEntity = BrandEntity.create(brand); + + return BrandEntity.toDomain(brandJpaRepository.save(brandEntity)); + } + + @Override + public Brand update(Long id, Brand brand) { + + BrandEntity brandEntity = brandJpaRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + brandEntity.update(brand); + + return BrandEntity.toDomain(brandJpaRepository.save(brandEntity)); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id) + .map(BrandEntity::toDomain); } } From 3821d0cb1b554bb9dcf2dadc148ab7e03f88880f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 19 Feb 2026 16:56:29 +0900 Subject: [PATCH 04/55] =?UTF-8?q?refactor=20:=20user=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/infrastructure/{ => user}/UserJpaRepository.java | 2 +- .../loopers/infrastructure/{ => user}/UserRepositoryImpl.java | 2 +- .../test/java/com/loopers/interfaces/api/UsersApiE2ETest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => user}/UserJpaRepository.java (89%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => user}/UserRepositoryImpl.java (96%) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index e1b9b5949..c8cf65de2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import com.loopers.domain.user.UserModel; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 19a304e68..92e97c259 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java index 1cfd7ef64..ef8f71ec2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UsersApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api; import com.loopers.domain.user.UserModel; -import com.loopers.infrastructure.UserJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.interfaces.user.ChangePasswordRequest; import com.loopers.interfaces.user.UserDto; import com.loopers.interfaces.user.UsersSignUpRequestDto; From 054c9eb0eb6275b101bf6951105628fc618a7e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 19 Feb 2026 22:29:13 +0900 Subject: [PATCH 05/55] =?UTF-8?q?test=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20BrandService=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/brand/BrandService.java | 8 +-- .../domain/brand/BrandServiceTest.java | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java 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 660959185..f0a10c6af 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 @@ -11,15 +11,15 @@ public class BrandService { private final BrandRepository brandRepository; - public void create(String name, String description) { + public Brand create(String name, String description) { Brand brand = Brand.create(null, name, description); - brandRepository.create(brand); + return brandRepository.create(brand); } - public void update(Long id, String name, String description) { + public Brand update(Long id, String name, String description) { Brand existing = findById(id); Brand updated = existing.update(name, description); - brandRepository.update(id, updated); + return brandRepository.update(id, updated); } public Brand findById(Long id) { 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..a1f7dd05d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceTest.java @@ -0,0 +1,59 @@ +package com.loopers.domain.brand; + +import static org.mockito.BDDMockito.given; + +import com.loopers.support.error.CoreException; +import java.util.Optional; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BrandServiceTest { + + @InjectMocks + private BrandService brandService; + + @Mock + private BrandRepository brandRepository; + + @Test + @DisplayName("브랜드 정보를 수정할 때 해당 브랜드가 존재하지않으면 예외를 던진다") + void fail_modify_not_found() { + Long id = 10L; + String name = "나이키"; + String description = "나이키설명"; + + given(brandRepository.findById(id)).willReturn(Optional.empty()); + + Assertions.assertThatThrownBy(() -> brandService.update(id, name, description)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } + + @Test + @DisplayName("id로 브랜드를 조회할 때 브랜드가 존재하지않으면 예외를 던진다") + void fail_findById_not_found_brand() { + Long id = 10L; + + given(brandRepository.findById(id)).willReturn(Optional.empty()); + + Assertions.assertThatThrownBy(() -> brandService.findById(id)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } + + // @Test +// void check_default_return() { +// Long id = 10L; +// +// Optional result = brandRepository.findById(id); +// +// System.out.println("result: " + result); +// System.out.println("isPresent: " + result.isPresent()); +// } +} From cc5b091f0a56477e7de103b4e46bb26a45b4db52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 20 Feb 2026 10:33:34 +0900 Subject: [PATCH 06/55] =?UTF-8?q?refactor=20:=20BaseEntity=20id=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=B6=80=EB=B6=84=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/infrastructure/brand/BrandEntity.java | 5 ++++- .../com/loopers/domain/brand/BrandServiceTest.java | 14 +++++++++++++- .../loopers/domain/example/ExampleModelTest.java | 2 +- .../main/java/com/loopers/domain/BaseEntity.java | 2 +- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java index 65f58d6ce..343b19147 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -27,7 +27,10 @@ private BrandEntity(String name, String description) { } public static BrandEntity create(Brand brand) { - return new BrandEntity(brand.getName(), brand.getDescription()); + return new BrandEntity( + brand.getName(), + brand.getDescription() + ); } public static Brand toDomain(BrandEntity brandEntity) { 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 index a1f7dd05d..ecd8ffc80 100644 --- 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 @@ -47,7 +47,7 @@ void fail_findById_not_found_brand() { .hasMessage("브랜드를 찾을 수 없습니다"); } - // @Test +// @Test // void check_default_return() { // Long id = 10L; // @@ -56,4 +56,16 @@ void fail_findById_not_found_brand() { // System.out.println("result: " + result); // System.out.println("isPresent: " + result.isPresent()); // } + +// @Test +// @DisplayName("BrandEntity를 생성할 때 ID 초기값을 확인한다") +// void check_entity_initial_id() { +// Brand brand = Brand.create(null, "테스트", "설명"); +// +// BrandEntity entity = BrandEntity.create(brand); +// +// System.out.println("생성된 엔티티의 ID: " + entity.getId()); +// +// Assertions.assertThat(entity.getId()).isEqualTo(null); +// } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java index 44ca7576e..2a47cbf04 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/example/ExampleModelTest.java @@ -26,7 +26,7 @@ void createsExampleModel_whenNameAndDescriptionAreProvided() { // assert assertAll( - () -> assertThat(exampleModel.getId()).isNotNull(), + () -> assertThat(exampleModel.getId()).isNull(), () -> assertThat(exampleModel.getName()).isEqualTo(name), () -> assertThat(exampleModel.getDescription()).isEqualTo(description) ); diff --git a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java index d15a9c764..72fdc2ece 100644 --- a/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java +++ b/modules/jpa/src/main/java/com/loopers/domain/BaseEntity.java @@ -20,7 +20,7 @@ public abstract class BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private final Long id = 0L; + private Long id; @Column(name = "created_at", nullable = false, updatable = false) private ZonedDateTime createdAt; From 5c3816d97092d3a128604334a174ff2ed7ea97a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 20 Feb 2026 12:00:22 +0900 Subject: [PATCH 07/55] =?UTF-8?q?chore=20:=20=ED=81=B4=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20md=20=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 75c681157..108d97326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,14 +58,18 @@ infrastructure/ → JpaRepository 구현체 support/error/ → CoreException, ErrorType (에러 코드 enum) ``` -- Repository 패턴: domain에 인터페이스, infrastructure에 구현체 +- 본 프로젝트는 레이어드 아키텍처를 따르며, DIP (의존성 역전 원칙) 을 준수합니다. + - Repository 패턴: domain에 인터페이스, infrastructure에 구현체 +- API request, response DTO와 응용 레이어의 DTO는 분리해 작성 - Facade 패턴: application 레이어에서 여러 도메인 서비스 조합 - API 버전닝: `/api/v1/` 경로 기반 - 글로벌 예외 처리: `ApiControllerAdvice`에서 `CoreException` → `ApiResponse` 변환 -### BaseEntity (modules/jpa) - -모든 엔티티의 부모 클래스. Auto-increment ID, `createdAt`/`updatedAt`/`deletedAt` 자동 관리, `delete()`/`restore()` 소프트 삭제 지원. +#### 도메인 & 객체 설계 전략 +- 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. +- 애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공해야 합니다. +- 규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다. +- 각 기능에 대한 책임과 결합도에 대해 개발자의 의도를 확인하고 개발을 진행합니다. ## Testing @@ -118,3 +122,5 @@ support/error/ → CoreException, ErrorType (에러 코드 enum) - 불필요한 코드 제거 및 품질 개선 - 객체지향적 코드 작성, 성능 최적화 - 모든 테스트 케이스가 통과해야 함 + + From 686da5d1ec76c1547eecb21932bd2973f6ce3b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 20 Feb 2026 15:15:59 +0900 Subject: [PATCH 08/55] =?UTF-8?q?feat=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20&=20DB=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 88 +++++++++++++++++++ .../loopers/infrastructure/ProductEntity.java | 33 +++++++ 2 files changed, 121 insertions(+) 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/infrastructure/ProductEntity.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..b6d3f653a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,88 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * Product 도메인 + */ +public class Product { + + private final Long id; + private final String name; + private final Long refBrandId; + private final Integer price; + private Integer stock; + + private Product(Long id, String name, Long refBrandId, Integer price, Integer stock) { + this.id = id; + this.name = name; + this.refBrandId = refBrandId; + this.price = price; + this.stock = stock; + } + + public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock) { + validateName(name); + validateBrandId(refBrandId); + validatePrice(price); + validateStock(stock); + + return new Product(id, name, refBrandId, price, stock); + } + + private static void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수 입니다"); + } + } + + private static void validateBrandId(Long refBrand) { + if (refBrand == null || refBrand <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드FK는 null이거나 0이하가 될 수 없습니다"); + } + } + + private static void validatePrice(Integer price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 null이거나 음수가 될 수 없습니다"); + } + } + + private static void validateStock(Integer stock) { + if (stock == null || stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 null이거나 음수가 될 수 없습니다"); + } + } + + public boolean hasEnoughStock(int requiredQuantity) { + return this.stock >= requiredQuantity; + } + + public void decreaseStock(int requiredQuantity) { + if (!hasEnoughStock(requiredQuantity)) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); + } + this.stock -= requiredQuantity; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getRefBrandId() { + return refBrandId; + } + + public Integer getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductEntity.java new file mode 100644 index 000000000..2bbf094a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductEntity.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure; + +import jakarta.persistence.Column; +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * Product DB 엔티티 + */ +@Entity +@Table(name = "product") +@NoArgsConstructor +public class ProductEntity extends BaseEntity { + + @Comment("상품 이름") + @Column(name = "name", nullable = false) + private String name; + + @Comment("브랜드 id (ref)") + @Column(name = "ref_brand_id", nullable = false) + private Long refBrandId; + + @Comment("현재 판매가") + @Column(name = "price", nullable = false) + private Long price; + + @Comment("현재 재고") + @Column(name = "stock", nullable = false) + private Integer quantity; +} From 0f473a77db797140aa543b2c541b1153df9c3659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 20 Feb 2026 15:17:11 +0900 Subject: [PATCH 09/55] =?UTF-8?q?test=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductTest.java | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java 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..82ff83f92 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,122 @@ +package com.loopers.domain.product; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +public class ProductTest { + + @DisplayName("상품 도메인 객체 생성 테스트") + @Test + void success_create_product() { + String name = "product1"; + Long refBrandId = 105L; + Integer price = 1000; + Integer stock = 100; + + Product product = Product.create(null, name, refBrandId, price, stock); + + assertThat(product).isNotNull(); + assertThat(product.getId()).isEqualTo(null); + assertThat(product.getName()).isEqualTo(name); + assertThat(product.getRefBrandId()).isEqualTo(refBrandId); + assertThat(product.getPrice()).isEqualTo(price); + assertThat(product.getStock()).isEqualTo(stock); + } + + @DisplayName("상품 이름이 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_create_product_with_invalid_name(String name) { + Long refBrandId = 105L; + Integer price = 1000; + Integer stock = 100; + + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 이름은 필수 입니다"); + } + + @DisplayName("상품의 브랜드 정보가 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-10000L, -1L, 0L}) + void fail_create_product_with_invalid_brand_id(Long refBrandId) { + String name = "product1"; + Integer price = 1000; + Integer stock = 100; + + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드FK는 null이거나 0이하가 될 수 없습니다"); + } + + @DisplayName("상품 가격이 null이거나 음수라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-10000, -1}) + void fail_create_product_with_invalid_price(Integer price) { + String name = "product1"; + Long refBrandId = 105L; + Integer stock = 100; + + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 가격은 null이거나 음수가 될 수 없습니다"); + } + + @DisplayName("상품 재고가 null이거나 0 이하라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-100, -1}) + void fail_create_product_with_invalid_stock(Integer stock) { + String name = "product1"; + Long refBrandId = 105L; + Integer price = 1000; + + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 재고는 null이거나 음수가 될 수 없습니다"); + } + + @DisplayName("요청 수량보다 재고가 많으면 true, 재고가 적으면 false") + @ParameterizedTest(name = "재고={0}, 요청={1} → {2}") + @CsvSource({ + "1000, 1, true", + "1, 1000, false" + }) + void validate_stock_by_required_quantity(int stock, int requiredQuantity, boolean expected) { + Product product = Product.create(null, "product1", 105L, 1000, stock); + + assertThat(product.hasEnoughStock(requiredQuantity)).isEqualTo(expected); + } + + @DisplayName("재고 차감에 성공하면, 재고가 요청 수량만큼 줄어든다") + @Test + void success_decrease_stock() { + Product product = Product.create(null, "product1", 105L, 1000, 100); + + product.decreaseStock(30); + + assertThat(product.getStock()).isEqualTo(70); + } + + @DisplayName("재고보다 많은 수량을 차감하면, 예외를 던진다") + @Test + void fail_decrease_stock_when_not_enough() { + Product product = Product.create(null, "product1", 105L, 1000, 10); + + assertThatThrownBy(() -> product.decreaseStock(100)) + .isInstanceOf(CoreException.class) + .hasMessage("재고가 부족합니다"); + } +} From 41f35277fefaa18b6d7be18c0445a0d99dc72a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 20 Feb 2026 15:17:45 +0900 Subject: [PATCH 10/55] =?UTF-8?q?refactor=20:=20(=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EB=93=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20record=20->=20class?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/Brand.java | 37 ++++++++++++------- .../loopers/domain/brand/BrandService.java | 4 +- .../infrastructure/brand/BrandEntity.java | 5 ++- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index cc5ce9c91..e7937465a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -4,20 +4,40 @@ import com.loopers.support.error.ErrorType; import java.time.ZonedDateTime; -public record Brand(Long id, String name, String description, ZonedDateTime createdAt) { +/** + * Brand 도메인 + */ +public class Brand { + + private final Long id; + private String name; + private String description; + + private Brand(Long id, String name, String description) { + this.id = id; + this.name = name; + this.description = description; + } public static Brand create(Long id, String name, String description) { validateName(name); - return new Brand(id, name, description, null); + return new Brand(id, name, description); } private static void validateName(String name) { - if(name == null || name.isBlank()) { + if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수 입니다"); } } + public void update(String name, String description) { + validateName(name); + + this.name = name; + this.description = description; + } + public Long getId() { return id; } @@ -29,15 +49,4 @@ public String getName() { public String getDescription() { return description; } - - public Brand update(String name, String description) { - validateName(name); - - return new Brand( - this.id, - name, - description, - this.createdAt - ); - } } 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 f0a10c6af..b25dd213c 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 @@ -18,8 +18,8 @@ public Brand create(String name, String description) { public Brand update(Long id, String name, String description) { Brand existing = findById(id); - Brand updated = existing.update(name, description); - return brandRepository.update(id, updated); + existing.update(name, description); + return brandRepository.update(id, existing); } public Brand findById(Long id) { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java index 343b19147..c9930c2de 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -8,6 +8,9 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; +/** + * Brand DB 엔티티 + */ @Entity @Table(name = "brand") @NoArgsConstructor @@ -34,7 +37,7 @@ public static BrandEntity create(Brand brand) { } public static Brand toDomain(BrandEntity brandEntity) { - return new Brand(brandEntity.getId(), brandEntity.getName(), brandEntity.getDescription(), brandEntity.getCreatedAt()); + return Brand.create(brandEntity.getId(), brandEntity.getName(), brandEntity.getDescription()); } public void update(Brand brand) { From 2dba98dea959a2383af3844359193a45f50cb091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 20 Feb 2026 15:57:24 +0900 Subject: [PATCH 11/55] =?UTF-8?q?feat=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20repository=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20&=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductRepository.java | 5 +++++ .../infrastructure/{ => product}/ProductEntity.java | 2 +- .../infrastructure/product/ProductJpaRepository.java | 6 ++++++ .../product/ProductRepositoryImpl.java | 12 ++++++++++++ .../loopers/domain/product/ProductServiceTest.java | 5 +++++ 5 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => product}/ProductEntity.java (94%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java 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..a977c0f3a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public interface ProductRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 2bbf094a3..7768073e5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.product; import jakarta.persistence.Column; import com.loopers.domain.BaseEntity; 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..f3b314226 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,6 @@ +package com.loopers.infrastructure.product; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 000000000..52677ecfb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; +} 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..d246d4b8d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceTest.java @@ -0,0 +1,5 @@ +package com.loopers.domain.product; + +public class ProductServiceTest { + +} From 57ff003c407a79ad372d57bd85411b418f5d2b67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 21 Feb 2026 09:48:44 +0900 Subject: [PATCH 12/55] =?UTF-8?q?feat=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EC=A1=B4=EC=9E=AC=20=EC=97=AC=EB=B6=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20&=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/brand/BrandRepository.java | 1 + .../com/loopers/domain/brand/BrandService.java | 6 ++++++ .../infrastructure/brand/BrandRepositoryImpl.java | 5 +++++ .../com/loopers/domain/brand/BrandServiceTest.java | 14 +++++++++++++- 4 files changed, 25 insertions(+), 1 deletion(-) 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 index 69869647e..c0d31888c 100644 --- 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 @@ -7,4 +7,5 @@ public interface BrandRepository { Brand create(Brand brand); Brand update(Long id, Brand brand); Optional findById(Long id); + boolean existsById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index b25dd213c..67257498a 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 @@ -26,4 +26,10 @@ public Brand findById(Long id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")); } + + public void validateExists(Long id) { + if (!brandRepository.existsById(id)) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다"); + } + } } 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 index c222a386f..e55f4cb31 100644 --- 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 @@ -36,4 +36,9 @@ public Optional findById(Long id) { return brandJpaRepository.findById(id) .map(BrandEntity::toDomain); } + + @Override + public boolean existsById(Long id) { + return brandJpaRepository.existsById(id); + } } 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 index ecd8ffc80..16a85246e 100644 --- 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 @@ -47,7 +47,19 @@ void fail_findById_not_found_brand() { .hasMessage("브랜드를 찾을 수 없습니다"); } -// @Test + @Test + @DisplayName("브랜드 존재 검증 시 브랜드가 존재하지 않으면 예외를 던진다") + void fail_validateExists_not_found() { + Long id = 10L; + + given(brandRepository.existsById(id)).willReturn(false); + + Assertions.assertThatThrownBy(() -> brandService.validateExists(id)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } + + // @Test // void check_default_return() { // Long id = 10L; // From 502f010073eac0eedc8ea110074f78792aaf6ba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 21 Feb 2026 10:46:21 +0900 Subject: [PATCH 13/55] =?UTF-8?q?feat=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=81=ED=92=88=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/brand/BrandValidator.java | 19 +++++++++ .../domain/product/CreateProductRequest.java | 8 ++++ .../domain/product/ProductRepository.java | 1 + .../domain/product/ProductService.java | 30 ++++++++++++++ .../infrastructure/product/ProductEntity.java | 41 ++++++++++++++++++- .../product/ProductRepositoryImpl.java | 8 ++++ 6 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java new file mode 100644 index 000000000..7f8e49c34 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java @@ -0,0 +1,19 @@ +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; + +@Component +@RequiredArgsConstructor +public class BrandValidator { + + private final BrandRepository brandRepository; + + public void validateExists(Long id) { + if (!brandRepository.existsById(id)) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java new file mode 100644 index 000000000..bbfbbcf10 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/CreateProductRequest.java @@ -0,0 +1,8 @@ +package com.loopers.domain.product; + +public record CreateProductRequest( + String name, + Integer price, + Integer stock +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index a977c0f3a..51d2594d3 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 @@ -2,4 +2,5 @@ public interface ProductRepository { + 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..47044e51d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.product; + +import com.loopers.domain.brand.BrandValidator; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + private final BrandValidator brandValidator; + + public void createProducts(Map createProductsCommand) { + createProductsCommand.keySet().forEach(brandValidator::validateExists); + + createProductsCommand.forEach((brandId, request) -> { + Product product = Product.create( + null, + request.name(), + brandId, + request.price(), + request.stock() + ); + productRepository.save(product); + }); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 7768073e5..a457fb135 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -1,7 +1,8 @@ package com.loopers.infrastructure.product; -import jakarta.persistence.Column; import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Product; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.NoArgsConstructor; @@ -25,9 +26,45 @@ public class ProductEntity extends BaseEntity { @Comment("현재 판매가") @Column(name = "price", nullable = false) - private Long price; + private Integer price; @Comment("현재 재고") @Column(name = "stock", nullable = false) private Integer quantity; + + public ProductEntity(Product product) { + this.name = product.getName(); + this.refBrandId = product.getRefBrandId(); + this.price = product.getPrice(); + } + + public static ProductEntity create(Product product) { + return new ProductEntity(product); + } + + public static Product toDomain(ProductEntity productEntity) { + return Product.create( + productEntity.getId(), + productEntity.getName(), + productEntity.getRefBrandId(), + productEntity.getPrice(), + productEntity.getQuantity() + ); + } + + public String getName() { + return name; + } + + public Long getRefBrandId() { + return refBrandId; + } + + public Integer getPrice() { + return price; + } + + public Integer getQuantity() { + return quantity; + } } 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 52677ecfb..6d6ef7a8b 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 @@ -1,5 +1,6 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -9,4 +10,11 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + ProductEntity productEntity = ProductEntity.create(product); + + return ProductEntity.toDomain(productJpaRepository.save(productEntity)); + } } From a29112b73921499d8115314d6f8db238d68459e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 21 Feb 2026 11:06:15 +0900 Subject: [PATCH 14/55] =?UTF-8?q?test=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/brand/BrandService.java | 6 ---- .../domain/brand/BrandServiceTest.java | 13 +------ .../domain/brand/BrandValidatorTest.java | 34 +++++++++++++++++++ 3 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java 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 67257498a..b25dd213c 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 @@ -26,10 +26,4 @@ public Brand findById(Long id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")); } - - public void validateExists(Long id) { - if (!brandRepository.existsById(id)) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다"); - } - } } 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 index 16a85246e..aaa3a2166 100644 --- 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 @@ -47,19 +47,8 @@ void fail_findById_not_found_brand() { .hasMessage("브랜드를 찾을 수 없습니다"); } - @Test - @DisplayName("브랜드 존재 검증 시 브랜드가 존재하지 않으면 예외를 던진다") - void fail_validateExists_not_found() { - Long id = 10L; - - given(brandRepository.existsById(id)).willReturn(false); - - Assertions.assertThatThrownBy(() -> brandService.validateExists(id)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드를 찾을 수 없습니다"); - } - // @Test +// @Test // void check_default_return() { // Long id = 10L; // diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java new file mode 100644 index 000000000..996509ebc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java @@ -0,0 +1,34 @@ +package com.loopers.domain.brand; + +import static org.mockito.BDDMockito.given; + +import com.loopers.support.error.CoreException; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BrandValidatorTest { + + @InjectMocks + private BrandValidator brandValidator; + + @Mock + private BrandRepository brandRepository; + + @Test + @DisplayName("브랜드가 존재하지 않으면 예외를 던진다") + void fail_validateExists_not_found() { + Long id = 10L; + + given(brandRepository.existsById(id)).willReturn(false); + + Assertions.assertThatThrownBy(() -> brandValidator.validateExists(id)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } +} From 78df4b76d7aa70c2b4322140e3eee7ce369e8f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 21 Feb 2026 11:38:23 +0900 Subject: [PATCH 15/55] =?UTF-8?q?test=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=81=ED=92=88=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductService.java | 11 +++++- .../domain/product/ProductServiceTest.java | 37 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) 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 47044e51d..cc58849ca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,8 @@ package com.loopers.domain.product; import com.loopers.domain.brand.BrandValidator; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,9 +15,11 @@ public class ProductService { private final BrandValidator brandValidator; - public void createProducts(Map createProductsCommand) { + public List createProducts(Map createProductsCommand) { createProductsCommand.keySet().forEach(brandValidator::validateExists); + List createdProducts = new ArrayList<>(); + createProductsCommand.forEach((brandId, request) -> { Product product = Product.create( null, @@ -24,7 +28,10 @@ public void createProducts(Map createProductsCommand request.price(), request.stock() ); - productRepository.save(product); + Product savedProduct = productRepository.save(product); + createdProducts.add(savedProduct); }); + + return createdProducts; } } 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 d246d4b8d..5379f0ceb 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 @@ -1,5 +1,40 @@ package com.loopers.domain.product; -public class ProductServiceTest { +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.willThrow; +import com.loopers.domain.brand.BrandValidator; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @InjectMocks + private ProductService productService; + + @Mock + private BrandValidator brandValidator; + + @Test + @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") + void fail_when_brand_not_found() { + Long brandId = 999L; + CreateProductRequest request = new CreateProductRequest("product1", 100000, 10); + Map command = Map.of(brandId, request); + + willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) + .given(brandValidator).validateExists(brandId); + + assertThatThrownBy(() -> productService.createProducts(command)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } } From aaa87a6bdea35160bf8a049909cb6d8f52075910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 21 Feb 2026 12:12:47 +0900 Subject: [PATCH 16/55] =?UTF-8?q?feat=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=81=ED=92=88=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EA=B0=90=EC=86=8C=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductRepository.java | 6 ++++++ .../domain/product/ProductService.java | 17 +++++++++++++++++ .../infrastructure/product/ProductEntity.java | 7 +++++++ .../product/ProductRepositoryImpl.java | 19 +++++++++++++++++++ 4 files changed, 49 insertions(+) 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 51d2594d3..fc4fd0fa3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,6 +1,12 @@ package com.loopers.domain.product; +import java.util.Optional; + public interface ProductRepository { Product save(Product product); + + Optional findById(Long id); + + Product update(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 cc58849ca..5c9ad5906 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,8 @@ package com.loopers.domain.product; import com.loopers.domain.brand.BrandValidator; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -34,4 +36,19 @@ public List createProducts(Map createProduc return createdProducts; } + + public void decreaseStock(Long productId, Integer decreaseStock) { + // 상품이 존재해? + Product product = findById(productId); + + // 상품의 수량이 충분해? + 수량 감소 + product.decreaseStock(decreaseStock); + + productRepository.update(product); + } + + public Product findById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "상품을 찾을 수 없습니다")); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index a457fb135..2247f4fd4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -67,4 +67,11 @@ public Integer getPrice() { public Integer getQuantity() { return quantity; } + + public void update(Product product) { + this.name = product.getName(); + this.refBrandId = product.getRefBrandId(); + this.price = product.getPrice(); + this.quantity = product.getStock(); + } } 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 6d6ef7a8b..5a1dbe739 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -2,6 +2,10 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -17,4 +21,19 @@ public Product save(Product product) { return ProductEntity.toDomain(productJpaRepository.save(productEntity)); } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id) + .map(ProductEntity::toDomain); + } + + @Override + public Product update(Product product) { + ProductEntity productEntity = productJpaRepository.findById(product.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + productEntity.update(product); + + return ProductEntity.toDomain(productJpaRepository.save(productEntity)); + } } From 7b3a1956b53eb7467fad2a38f03bb85e82ea3c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 21 Feb 2026 12:15:52 +0900 Subject: [PATCH 17/55] =?UTF-8?q?remove=20:=20(=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=B8=8C=EB=9E=9C=EB=93=9C?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20update=20=ED=95=A0=20=EB=95=8C=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/brand/BrandRepository.java | 2 +- .../main/java/com/loopers/domain/brand/BrandService.java | 6 +++--- .../loopers/infrastructure/brand/BrandRepositoryImpl.java | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) 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 index c0d31888c..c82cecbfe 100644 --- 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 @@ -5,7 +5,7 @@ public interface BrandRepository { Brand create(Brand brand); - Brand update(Long id, Brand brand); + Brand update(Brand brand); Optional findById(Long id); boolean existsById(Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index b25dd213c..8d102d2a2 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 @@ -17,9 +17,9 @@ public Brand create(String name, String description) { } public Brand update(Long id, String name, String description) { - Brand existing = findById(id); - existing.update(name, description); - return brandRepository.update(id, existing); + Brand brand = findById(id); + brand.update(name, description); + return brandRepository.update(brand); } public Brand findById(Long id) { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index e55f4cb31..bf00f130e 100644 --- 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 @@ -22,9 +22,8 @@ public Brand create(Brand brand) { } @Override - public Brand update(Long id, Brand brand) { - - BrandEntity brandEntity = brandJpaRepository.findById(id) + public Brand update(Brand brand) { + BrandEntity brandEntity = brandJpaRepository.findById(brand.getId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); brandEntity.update(brand); From 5bc2ad56c12ddcbd8e4ab3b2bc4c8f88818c2e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sun, 22 Feb 2026 16:59:01 +0900 Subject: [PATCH 18/55] =?UTF-8?q?test=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=81=ED=92=88=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=EA=B0=90=EC=86=8C=EC=8B=9C=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EC=9D=B4=20=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4,=20=EC=98=88=EC=99=B8=20=EB=8D=98=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductService.java | 2 -- .../domain/product/ProductServiceTest.java | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) 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 5c9ad5906..3ec9b73e0 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 @@ -38,10 +38,8 @@ public List createProducts(Map createProduc } public void decreaseStock(Long productId, Integer decreaseStock) { - // 상품이 존재해? Product product = findById(productId); - // 상품의 수량이 충분해? + 수량 감소 product.decreaseStock(decreaseStock); productRepository.update(product); 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 5379f0ceb..f78dd1ef6 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 @@ -1,12 +1,14 @@ package com.loopers.domain.product; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; import com.loopers.domain.brand.BrandValidator; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +25,9 @@ class ProductServiceTest { @Mock private BrandValidator brandValidator; + @Mock + private ProductRepository productRepository; + @Test @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") void fail_when_brand_not_found() { @@ -37,4 +42,17 @@ void fail_when_brand_not_found() { .isInstanceOf(CoreException.class) .hasMessage("브랜드를 찾을 수 없습니다"); } + + @Test + @DisplayName("상품 수량 감소시 상품이 존재하지 않으면, 예외를 던진다") + void fail_when_product_not_found() { + Long productId = 10101L; + Integer decreaseStock = 100; + + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> productService.decreaseStock(productId, decreaseStock)) + .isInstanceOf(CoreException.class) + .hasMessage("상품을 찾을 수 없습니다"); + } } From 5530876dc4a5c1883f763e8d907a3ab498358dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sun, 22 Feb 2026 18:01:31 +0900 Subject: [PATCH 19/55] =?UTF-8?q?feat=20:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=EA=B1=B4+=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductRepository.java | 3 ++ .../product/ProductSearchCondition.java | 16 +++++++++ .../domain/product/ProductService.java | 7 ++++ .../domain/product/ProductSortType.java | 7 ++++ .../product/ProductRepositoryImpl.java | 36 +++++++++++++++++-- 5 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java 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 fc4fd0fa3..a98f9e2a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import java.util.List; import java.util.Optional; public interface ProductRepository { @@ -9,4 +10,6 @@ public interface ProductRepository { Optional findById(Long id); Product update(Product product); + + List findAll(ProductSearchCondition condition); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java new file mode 100644 index 000000000..8e2ae6c15 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSearchCondition.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product; + +public record ProductSearchCondition( + Long brandId, + ProductSortType sortType, + int page, + int size +) { + public static ProductSearchCondition of(Long brandId, ProductSortType sortType, int page, int size) { + return new ProductSearchCondition(brandId, sortType, page, size); + } + + public boolean hasBrandId() { + return brandId != null; + } +} 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 3ec9b73e0..4112fad87 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 @@ -49,4 +49,11 @@ public Product findById(Long id) { return productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "상품을 찾을 수 없습니다")); } + + public List getProducts(ProductSearchCondition condition) { + if (condition.hasBrandId()) { + brandValidator.validateExists(condition.brandId()); + } + return productRepository.findAll(condition); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..721974520 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC +} 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 5a1dbe739..5c4ceabaa 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 @@ -1,10 +1,16 @@ package com.loopers.infrastructure.product; +import static com.loopers.infrastructure.product.QProductEntity.productEntity; + import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; -import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,6 +20,7 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; @Override public Product save(Product product) { @@ -31,9 +38,34 @@ public Optional findById(Long id) { @Override public Product update(Product product) { ProductEntity productEntity = productJpaRepository.findById(product.getId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); productEntity.update(product); return ProductEntity.toDomain(productJpaRepository.save(productEntity)); } + + @Override + public List findAll(ProductSearchCondition condition) { + return queryFactory + .selectFrom(productEntity) + .where( + condition.hasBrandId() ? productEntity.refBrandId.eq(condition.brandId()) : null + ) + .orderBy(getOrderSpecifier(condition.sortType())) + .offset((long) condition.page() * condition.size()) + .limit(condition.size()) + .fetch() + .stream() + .map(ProductEntity::toDomain) + .toList(); + } + + private OrderSpecifier getOrderSpecifier(ProductSortType sortType) { + return switch (sortType) { + case LATEST -> productEntity.createdAt.desc(); + case PRICE_ASC -> productEntity.price.asc(); +// case LIKES_DESC -> productEntity.createdAt.desc(); + case LIKES_DESC -> null; + }; + } } From 19873ecce9ef1fb7d443ff1b9ec3e257d6066d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sun, 22 Feb 2026 18:02:11 +0900 Subject: [PATCH 20/55] =?UTF-8?q?test=20:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=EA=B1=B4+=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4,=20=EC=98=88=EC=99=B8=20=EB=8D=98=EC=A7=80=EB=8A=94?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductServiceTest.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 f78dd1ef6..93e00b405 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 @@ -30,7 +30,7 @@ class ProductServiceTest { @Test @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") - void fail_when_brand_not_found() { + void fail_createProducts_when_brand_not_found() { Long brandId = 999L; CreateProductRequest request = new CreateProductRequest("product1", 100000, 10); Map command = Map.of(brandId, request); @@ -45,7 +45,7 @@ void fail_when_brand_not_found() { @Test @DisplayName("상품 수량 감소시 상품이 존재하지 않으면, 예외를 던진다") - void fail_when_product_not_found() { + void fail_decreaseStock_when_product_not_found() { Long productId = 10101L; Integer decreaseStock = 100; @@ -55,4 +55,18 @@ void fail_when_product_not_found() { .isInstanceOf(CoreException.class) .hasMessage("상품을 찾을 수 없습니다"); } + + @Test + @DisplayName("상품 목록 페이징 조회시 브랜드가 존재하지 않으면, 예외를 던진다") + void fail_getProducts_when_brand_not_found() { + Long brandId = 999L; + ProductSearchCondition condition = ProductSearchCondition.of(brandId, ProductSortType.LATEST, 0, 10); + + willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) + .given(brandValidator).validateExists(brandId); + + assertThatThrownBy(() -> productService.getProducts(condition)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } } From 41300ac1ee7759ae158456273e801edcdb9e1b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sun, 22 Feb 2026 18:08:23 +0900 Subject: [PATCH 21/55] =?UTF-8?q?feat=20:=20=EC=B0=A8=EA=B0=90=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=96=91=EC=88=98=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EB=8D=98=EC=A7=80?= =?UTF-8?q?=EB=8A=94=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20&=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/product/Product.java | 3 +++ .../java/com/loopers/domain/product/ProductTest.java | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index b6d3f653a..571d1d979 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -60,6 +60,9 @@ public boolean hasEnoughStock(int requiredQuantity) { } public void decreaseStock(int requiredQuantity) { + if (requiredQuantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "감소 수량은 양수여야 합니다"); + } if (!hasEnoughStock(requiredQuantity)) { throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 82ff83f92..6960f4b4e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -119,4 +119,14 @@ void fail_decrease_stock_when_not_enough() { .isInstanceOf(CoreException.class) .hasMessage("재고가 부족합니다"); } + + @DisplayName("재고 차감하려는 수량 파라미터가 양수가 아니면, 예외를 던진다") + @Test + void fail_decrease_stock_when_not_positive() { + Product product = Product.create(1L, "product1", 1L, 10000, 100); + + assertThatThrownBy(() -> product.decreaseStock(-10)) + .isInstanceOf(CoreException.class) + .hasMessage("감소 수량은 양수여야 합니다"); + } } From 7e27a70ac5b4e2fd1db64b906f089f8a1d1b26c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sun, 22 Feb 2026 18:45:50 +0900 Subject: [PATCH 22/55] =?UTF-8?q?refactor=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20null=20=EB=B0=A9=EC=96=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 18 +++++++++++------- .../loopers/domain/product/ProductService.java | 4 ++++ .../domain/product/ProductServiceTest.java | 11 +++++++++++ .../loopers/domain/product/ProductTest.java | 12 +++++++----- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 571d1d979..374864508 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -55,18 +55,22 @@ private static void validateStock(Integer stock) { } } - public boolean hasEnoughStock(int requiredQuantity) { + public boolean hasEnoughStock(Integer requiredQuantity) { return this.stock >= requiredQuantity; } - public void decreaseStock(int requiredQuantity) { - if (requiredQuantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "감소 수량은 양수여야 합니다"); - } - if (!hasEnoughStock(requiredQuantity)) { + public void decreaseStock(Integer quantity) { + validateQuantity(quantity); + if (!hasEnoughStock(quantity)) { throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); } - this.stock -= requiredQuantity; + this.stock -= quantity; + } + + private void validateQuantity(Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 양수여야 합니다"); + } } public Long getId() { 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 4112fad87..311456ad2 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 @@ -18,6 +18,10 @@ public class ProductService { private final BrandValidator brandValidator; public List createProducts(Map createProductsCommand) { + if (createProductsCommand == null || createProductsCommand.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 생성 요청은 필수입니다"); + } + createProductsCommand.keySet().forEach(brandValidator::validateExists); List createdProducts = new ArrayList<>(); 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 93e00b405..18aaa8a96 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 @@ -12,6 +12,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -28,6 +30,15 @@ class ProductServiceTest { @Mock private ProductRepository productRepository; + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 생성 요청이 null이거나 비어있으면, 예외를 던진다") + void fail_createProducts_when_command_is_null_or_empty(Map command) { + assertThatThrownBy(() -> productService.createProducts(command)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 생성 요청은 필수입니다"); + } + @Test @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") void fail_createProducts_when_brand_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 index 6960f4b4e..f19c082a9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -120,13 +120,15 @@ void fail_decrease_stock_when_not_enough() { .hasMessage("재고가 부족합니다"); } - @DisplayName("재고 차감하려는 수량 파라미터가 양수가 아니면, 예외를 던진다") - @Test - void fail_decrease_stock_when_not_positive() { + @DisplayName("재고 차감 수량이 null이거나 양수가 아니면, 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-10, -1, 0}) + void fail_decrease_stock_when_quantity_invalid(Integer quantity) { Product product = Product.create(1L, "product1", 1L, 10000, 100); - assertThatThrownBy(() -> product.decreaseStock(-10)) + assertThatThrownBy(() -> product.decreaseStock(quantity)) .isInstanceOf(CoreException.class) - .hasMessage("감소 수량은 양수여야 합니다"); + .hasMessage("수량은 양수여야 합니다"); } } From 4284c43478abd77ec456a6bf8c49acea0e351c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sun, 22 Feb 2026 18:54:44 +0900 Subject: [PATCH 23/55] =?UTF-8?q?refactor=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20null=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B4=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/product/ProductService.java | 3 +++ .../com/loopers/domain/product/ProductServiceTest.java | 8 ++++++++ 2 files changed, 11 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 311456ad2..fd9c85679 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 @@ -55,6 +55,9 @@ public Product findById(Long id) { } public List getProducts(ProductSearchCondition condition) { + if (condition == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "검색 조건은 필수입니다"); + } if (condition.hasBrandId()) { brandValidator.validateExists(condition.brandId()); } 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 18aaa8a96..94f9018fb 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 @@ -80,4 +80,12 @@ void fail_getProducts_when_brand_not_found() { .isInstanceOf(CoreException.class) .hasMessage("브랜드를 찾을 수 없습니다"); } + + @Test + @DisplayName("상품 목록 조회시 검색 조건이 null이면, 예외를 던진다") + void fail_getProducts_when_condition_is_null() { + assertThatThrownBy(() -> productService.getProducts(null)) + .isInstanceOf(CoreException.class) + .hasMessage("검색 조건은 필수입니다"); + } } From 14c12c958b53dd8e8d9cdadd8439210211be92c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 09:57:49 +0900 Subject: [PATCH 24/55] =?UTF-8?q?feat=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20likeCount=20=ED=95=84=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 22 ++++++++--- .../domain/product/ProductService.java | 3 +- .../infrastructure/product/ProductEntity.java | 28 ++++++++----- .../loopers/domain/product/ProductTest.java | 39 ++++++++++++++----- 4 files changed, 68 insertions(+), 24 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 374864508..3d03c4d1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -9,26 +9,30 @@ public class Product { private final Long id; - private final String name; private final Long refBrandId; - private final Integer price; + + private String name; + private Integer price; private Integer stock; + private Integer likeCount; - private Product(Long id, String name, Long refBrandId, Integer price, Integer stock) { + private Product(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { this.id = id; this.name = name; this.refBrandId = refBrandId; this.price = price; this.stock = stock; + this.likeCount = likeCount; } - public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock) { + public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { validateName(name); validateBrandId(refBrandId); validatePrice(price); validateStock(stock); + validateLike(likeCount); - return new Product(id, name, refBrandId, price, stock); + return new Product(id, name, refBrandId, price, stock, likeCount); } private static void validateName(String name) { @@ -55,6 +59,12 @@ private static void validateStock(Integer stock) { } } + private static void validateLike(Integer likeCount) { + if (likeCount == null || likeCount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 null이거나 음수가 될 수 없습니다"); + } + } + public boolean hasEnoughStock(Integer requiredQuantity) { return this.stock >= requiredQuantity; } @@ -92,4 +102,6 @@ public Integer getPrice() { public Integer getStock() { return stock; } + + public Integer getLikeCount() { return likeCount; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index fd9c85679..c8e760985 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 @@ -32,7 +32,8 @@ public List createProducts(Map createProduc request.name(), brandId, request.price(), - request.stock() + request.stock(), + 0 ); Product savedProduct = productRepository.save(product); createdProducts.add(savedProduct); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 2247f4fd4..0fdc52841 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -30,12 +30,17 @@ public class ProductEntity extends BaseEntity { @Comment("현재 재고") @Column(name = "stock", nullable = false) - private Integer quantity; + private Integer stock; + + @Comment("좋아요 수") + @Column(name = "like_count", nullable = false) + private Integer likeCount; public ProductEntity(Product product) { this.name = product.getName(); this.refBrandId = product.getRefBrandId(); this.price = product.getPrice(); + this.likeCount = product.getLikeCount(); } public static ProductEntity create(Product product) { @@ -48,10 +53,18 @@ public static Product toDomain(ProductEntity productEntity) { productEntity.getName(), productEntity.getRefBrandId(), productEntity.getPrice(), - productEntity.getQuantity() + productEntity.getStock(), + productEntity.getLikeCount() ); } + public void update(Product product) { + this.name = product.getName(); + this.refBrandId = product.getRefBrandId(); + this.price = product.getPrice(); + this.stock = product.getStock(); + } + public String getName() { return name; } @@ -64,14 +77,11 @@ public Integer getPrice() { return price; } - public Integer getQuantity() { - return quantity; + public Integer getStock() { + return stock; } - public void update(Product product) { - this.name = product.getName(); - this.refBrandId = product.getRefBrandId(); - this.price = product.getPrice(); - this.quantity = product.getStock(); + public Integer getLikeCount() { + return likeCount; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index f19c082a9..9113152d2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -21,8 +21,9 @@ void success_create_product() { Long refBrandId = 105L; Integer price = 1000; Integer stock = 100; + Integer likeCount = 0; - Product product = Product.create(null, name, refBrandId, price, stock); + Product product = Product.create(null, name, refBrandId, price, stock, likeCount); assertThat(product).isNotNull(); assertThat(product.getId()).isEqualTo(null); @@ -30,6 +31,7 @@ void success_create_product() { assertThat(product.getRefBrandId()).isEqualTo(refBrandId); assertThat(product.getPrice()).isEqualTo(price); assertThat(product.getStock()).isEqualTo(stock); + assertThat(product.getLikeCount()).isEqualTo(likeCount); } @DisplayName("상품 이름이 유효하지 않다면, 생성시 예외를 던진다") @@ -40,8 +42,9 @@ void fail_create_product_with_invalid_name(String name) { Long refBrandId = 105L; Integer price = 1000; Integer stock = 100; + Integer likeCount = 0; - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) .isInstanceOf(CoreException.class) .hasMessage("상품 이름은 필수 입니다"); } @@ -54,8 +57,9 @@ void fail_create_product_with_invalid_brand_id(Long refBrandId) { String name = "product1"; Integer price = 1000; Integer stock = 100; + Integer likeCount = 0; - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) .isInstanceOf(CoreException.class) .hasMessage("브랜드FK는 null이거나 0이하가 될 수 없습니다"); } @@ -68,8 +72,9 @@ void fail_create_product_with_invalid_price(Integer price) { String name = "product1"; Long refBrandId = 105L; Integer stock = 100; + Integer likeCount = 0; - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) .isInstanceOf(CoreException.class) .hasMessage("상품 가격은 null이거나 음수가 될 수 없습니다"); } @@ -82,12 +87,28 @@ void fail_create_product_with_invalid_stock(Integer stock) { String name = "product1"; Long refBrandId = 105L; Integer price = 1000; + Integer likeCount = 0; - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock)) + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) .isInstanceOf(CoreException.class) .hasMessage("상품 재고는 null이거나 음수가 될 수 없습니다"); } + @DisplayName("좋아요 수가 null이거나 0 미만이라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-100, -1}) + void fail_create_product_with_invalid_like_count(Integer likeCount) { + String name = "product1"; + Long refBrandId = 105L; + Integer price = 1000; + Integer stock = 100; + + assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) + .isInstanceOf(CoreException.class) + .hasMessage("좋아요 수는 null이거나 음수가 될 수 없습니다"); + } + @DisplayName("요청 수량보다 재고가 많으면 true, 재고가 적으면 false") @ParameterizedTest(name = "재고={0}, 요청={1} → {2}") @CsvSource({ @@ -95,7 +116,7 @@ void fail_create_product_with_invalid_stock(Integer stock) { "1, 1000, false" }) void validate_stock_by_required_quantity(int stock, int requiredQuantity, boolean expected) { - Product product = Product.create(null, "product1", 105L, 1000, stock); + Product product = Product.create(null, "product1", 105L, 1000, stock, 0); assertThat(product.hasEnoughStock(requiredQuantity)).isEqualTo(expected); } @@ -103,7 +124,7 @@ void validate_stock_by_required_quantity(int stock, int requiredQuantity, boolea @DisplayName("재고 차감에 성공하면, 재고가 요청 수량만큼 줄어든다") @Test void success_decrease_stock() { - Product product = Product.create(null, "product1", 105L, 1000, 100); + Product product = Product.create(null, "product1", 105L, 1000, 100, 0); product.decreaseStock(30); @@ -113,7 +134,7 @@ void success_decrease_stock() { @DisplayName("재고보다 많은 수량을 차감하면, 예외를 던진다") @Test void fail_decrease_stock_when_not_enough() { - Product product = Product.create(null, "product1", 105L, 1000, 10); + Product product = Product.create(null, "product1", 105L, 1000, 10, 0); assertThatThrownBy(() -> product.decreaseStock(100)) .isInstanceOf(CoreException.class) @@ -125,7 +146,7 @@ void fail_decrease_stock_when_not_enough() { @NullSource @ValueSource(ints = {-10, -1, 0}) void fail_decrease_stock_when_quantity_invalid(Integer quantity) { - Product product = Product.create(1L, "product1", 1L, 10000, 100); + Product product = Product.create(1L, "product1", 1L, 10000, 100, 0); assertThatThrownBy(() -> product.decreaseStock(quantity)) .isInstanceOf(CoreException.class) From 758e774a4d3e24cacdc18b2c1c622746873ad400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 10:03:59 +0900 Subject: [PATCH 25/55] =?UTF-8?q?refactor=20:=20ProductTest=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A4=91=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductTest.java | 242 +++++++++--------- 1 file changed, 124 insertions(+), 118 deletions(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 9113152d2..3006df835 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -5,6 +5,7 @@ import com.loopers.support.error.CoreException; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -14,142 +15,147 @@ public class ProductTest { - @DisplayName("상품 도메인 객체 생성 테스트") - @Test - void success_create_product() { - String name = "product1"; - Long refBrandId = 105L; - Integer price = 1000; - Integer stock = 100; - Integer likeCount = 0; - - Product product = Product.create(null, name, refBrandId, price, stock, likeCount); - - assertThat(product).isNotNull(); - assertThat(product.getId()).isEqualTo(null); - assertThat(product.getName()).isEqualTo(name); - assertThat(product.getRefBrandId()).isEqualTo(refBrandId); - assertThat(product.getPrice()).isEqualTo(price); - assertThat(product.getStock()).isEqualTo(stock); - assertThat(product.getLikeCount()).isEqualTo(likeCount); - } + private static final String DEFAULT_NAME = "product1"; + private static final Long DEFAULT_REF_BRAND_ID = 105L; + private static final Integer DEFAULT_PRICE = 1000; + private static final Integer DEFAULT_STOCK = 100; + private static final Integer DEFAULT_LIKE_COUNT = 0; - @DisplayName("상품 이름이 유효하지 않다면, 생성시 예외를 던진다") - @ParameterizedTest - @NullAndEmptySource - @ValueSource(strings = {" ", " "}) - void fail_create_product_with_invalid_name(String name) { - Long refBrandId = 105L; - Integer price = 1000; - Integer stock = 100; - Integer likeCount = 0; - - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) - .isInstanceOf(CoreException.class) - .hasMessage("상품 이름은 필수 입니다"); + private static Product createProduct(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { + return Product.create(id, name, refBrandId, price, stock, likeCount); } - @DisplayName("상품의 브랜드 정보가 유효하지 않다면, 생성시 예외를 던진다") - @ParameterizedTest - @NullSource - @ValueSource(longs = {-10000L, -1L, 0L}) - void fail_create_product_with_invalid_brand_id(Long refBrandId) { - String name = "product1"; - Integer price = 1000; - Integer stock = 100; - Integer likeCount = 0; - - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드FK는 null이거나 0이하가 될 수 없습니다"); + private static Product createProductWithStock(int stock) { + return createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, stock, DEFAULT_LIKE_COUNT); } - @DisplayName("상품 가격이 null이거나 음수라면, 생성시 예외를 던진다") - @ParameterizedTest - @NullSource - @ValueSource(ints = {-10000, -1}) - void fail_create_product_with_invalid_price(Integer price) { - String name = "product1"; - Long refBrandId = 105L; - Integer stock = 100; - Integer likeCount = 0; - - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) .isInstanceOf(CoreException.class) - .hasMessage("상품 가격은 null이거나 음수가 될 수 없습니다"); + .hasMessage(message); } - @DisplayName("상품 재고가 null이거나 0 이하라면, 생성시 예외를 던진다") - @ParameterizedTest - @NullSource - @ValueSource(ints = {-100, -1}) - void fail_create_product_with_invalid_stock(Integer stock) { - String name = "product1"; - Long refBrandId = 105L; - Integer price = 1000; - Integer likeCount = 0; - - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) - .isInstanceOf(CoreException.class) - .hasMessage("상품 재고는 null이거나 음수가 될 수 없습니다"); + @Nested + @DisplayName("상품 생성") + class Create { + + @DisplayName("상품 도메인 객체 생성 테스트") + @Test + void success_create_product() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, DEFAULT_LIKE_COUNT); + + assertThat(product).isNotNull(); + assertThat(product.getId()).isNull(); + assertThat(product.getName()).isEqualTo(DEFAULT_NAME); + assertThat(product.getRefBrandId()).isEqualTo(DEFAULT_REF_BRAND_ID); + assertThat(product.getPrice()).isEqualTo(DEFAULT_PRICE); + assertThat(product.getStock()).isEqualTo(DEFAULT_STOCK); + assertThat(product.getLikeCount()).isEqualTo(DEFAULT_LIKE_COUNT); + } + + @DisplayName("상품 이름이 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_when_invalid_name(String name) { + assertCoreException( + () -> createProduct(null, name, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), + "상품 이름은 필수 입니다" + ); + } + + @DisplayName("상품의 브랜드 정보가 유효하지 않다면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-10000L, -1L, 0L}) + void fail_when_invalid_brand_id(Long refBrandId) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, refBrandId, DEFAULT_PRICE, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), + "브랜드FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @DisplayName("상품 가격이 null이거나 음수라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-10000, -1}) + void fail_when_invalid_price(Integer price) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, price, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), + "상품 가격은 null이거나 음수가 될 수 없습니다" + ); + } + + @DisplayName("상품 재고가 null이거나 0 이하라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-100, -1}) + void fail_when_invalid_stock(Integer stock) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, stock, DEFAULT_LIKE_COUNT), + "상품 재고는 null이거나 음수가 될 수 없습니다" + ); + } + + @DisplayName("좋아요 수가 null이거나 0 미만이라면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-100, -1}) + void fail_when_invalid_like_count(Integer likeCount) { + assertCoreException( + () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, likeCount), + "좋아요 수는 null이거나 음수가 될 수 없습니다" + ); + } } - @DisplayName("좋아요 수가 null이거나 0 미만이라면, 생성시 예외를 던진다") - @ParameterizedTest - @NullSource - @ValueSource(ints = {-100, -1}) - void fail_create_product_with_invalid_like_count(Integer likeCount) { - String name = "product1"; - Long refBrandId = 105L; - Integer price = 1000; - Integer stock = 100; - - assertThatThrownBy(() -> Product.create(null, name, refBrandId, price, stock, likeCount)) - .isInstanceOf(CoreException.class) - .hasMessage("좋아요 수는 null이거나 음수가 될 수 없습니다"); + @Nested + @DisplayName("재고 검증") + class StockValidation { + + @DisplayName("요청 수량보다 재고가 많으면 true, 재고가 적으면 false") + @ParameterizedTest(name = "재고={0}, 요청={1} → {2}") + @CsvSource({ + "1000, 1, true", + "1, 1000, false" + }) + void validate_stock_by_required_quantity(int stock, int requiredQuantity, boolean expected) { + Product product = createProductWithStock(stock); + + assertThat(product.hasEnoughStock(requiredQuantity)).isEqualTo(expected); + } } - @DisplayName("요청 수량보다 재고가 많으면 true, 재고가 적으면 false") - @ParameterizedTest(name = "재고={0}, 요청={1} → {2}") - @CsvSource({ - "1000, 1, true", - "1, 1000, false" - }) - void validate_stock_by_required_quantity(int stock, int requiredQuantity, boolean expected) { - Product product = Product.create(null, "product1", 105L, 1000, stock, 0); + @Nested + @DisplayName("재고 차감") + class StockDecrease { - assertThat(product.hasEnoughStock(requiredQuantity)).isEqualTo(expected); - } + @DisplayName("재고 차감에 성공하면, 재고가 요청 수량만큼 줄어든다") + @Test + void success_decrease_stock() { + Product product = createProductWithStock(100); - @DisplayName("재고 차감에 성공하면, 재고가 요청 수량만큼 줄어든다") - @Test - void success_decrease_stock() { - Product product = Product.create(null, "product1", 105L, 1000, 100, 0); + product.decreaseStock(30); - product.decreaseStock(30); + assertThat(product.getStock()).isEqualTo(70); + } - assertThat(product.getStock()).isEqualTo(70); - } + @DisplayName("재고보다 많은 수량을 차감하면, 예외를 던진다") + @Test + void fail_when_not_enough() { + Product product = createProductWithStock(10); - @DisplayName("재고보다 많은 수량을 차감하면, 예외를 던진다") - @Test - void fail_decrease_stock_when_not_enough() { - Product product = Product.create(null, "product1", 105L, 1000, 10, 0); + assertCoreException(() -> product.decreaseStock(100), "재고가 부족합니다"); + } - assertThatThrownBy(() -> product.decreaseStock(100)) - .isInstanceOf(CoreException.class) - .hasMessage("재고가 부족합니다"); - } + @DisplayName("재고 차감 수량이 null이거나 양수가 아니면, 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-10, -1, 0}) + void fail_when_quantity_invalid(Integer quantity) { + Product product = createProduct(1L, DEFAULT_NAME, 1L, 10000, 100, 0); - @DisplayName("재고 차감 수량이 null이거나 양수가 아니면, 예외를 던진다") - @ParameterizedTest - @NullSource - @ValueSource(ints = {-10, -1, 0}) - void fail_decrease_stock_when_quantity_invalid(Integer quantity) { - Product product = Product.create(1L, "product1", 1L, 10000, 100, 0); - - assertThatThrownBy(() -> product.decreaseStock(quantity)) - .isInstanceOf(CoreException.class) - .hasMessage("수량은 양수여야 합니다"); + assertCoreException(() -> product.decreaseStock(quantity), "수량은 양수여야 합니다"); + } } } From d266b5850192b2beb42ab3f29b1b51d608ff7821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 11:12:27 +0900 Subject: [PATCH 26/55] =?UTF-8?q?feat=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EB=B0=8F=20jpa=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/like/Like.java | 8 ++++++ .../loopers/domain/like/LikeRepository.java | 5 ++++ .../infrastructure/like/LikeEntity.java | 28 +++++++++++++++++++ .../like/LikeJpaRepository.java | 7 +++++ .../like/LikeRepositoryImpl.java | 12 ++++++++ 5 files changed, 60 insertions(+) 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/infrastructure/like/LikeEntity.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 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..fb5a4737d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,8 @@ +package com.loopers.domain.like; + +public class Like { + + private Long id; + private Long refProductId; + private Long refUserId; +} 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..a18ad282c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,5 @@ +package com.loopers.domain.like; + +public interface LikeRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java new file mode 100644 index 000000000..b3e7bdf1e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "like") +public class LikeEntity extends BaseEntity { + + @Comment("상품 id (ref)") + @Column(name = "ref_product_id", nullable = false) + private Long refProductId; + + @Comment("유저 id (ref)") + @Column(name = "ref_user_id", nullable = false) + private Long refUserId; + + public Long getRefProductId() { + return refProductId; + } + + public Long getRefUserId() { + return refUserId; + } +} 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..4b1fe3d9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.like; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LikeJpaRepository extends JpaRepository { + +} 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..8ce3498c5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,12 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + +} From b339b4d38c4181d6babfc134b0ed4de2c055d88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 13:59:25 +0900 Subject: [PATCH 27/55] =?UTF-8?q?feat=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=A6=9D=EA=B0=90=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/product/Product.java | 11 ++++++ .../loopers/domain/product/ProductTest.java | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 3d03c4d1e..1b2abd608 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -77,6 +77,17 @@ public void decreaseStock(Integer quantity) { this.stock -= quantity; } + public void increaseLikeCount() { + this.likeCount += 1; + } + + public void decreaseLikeCount() { + if(this.likeCount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 갯수는 음수가 될 수 없습니다"); + } + this.likeCount -= 1; + } + private void validateQuantity(Integer quantity) { if (quantity == null || quantity <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, "수량은 양수여야 합니다"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 3006df835..1db60a885 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -158,4 +158,38 @@ void fail_when_quantity_invalid(Integer quantity) { assertCoreException(() -> product.decreaseStock(quantity), "수량은 양수여야 합니다"); } } + + @Nested + @DisplayName("좋아요 수 변경") + class LikeCount { + + @DisplayName("좋아요 수 증가 성공") + @Test + void success_increase() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, 5); + + product.increaseLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(6); + } + + @DisplayName("좋아요 수 감소 성공") + @Test + void success_decrease() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, 5); + + product.decreaseLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(4); + } + + @DisplayName("좋아요 수가 0일 때 감소하면, 예외를 던진다") + @Test + void fail_decrease_when_like_count_is_zero() { + Product product = createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, DEFAULT_PRICE, DEFAULT_STOCK, 0); + + assertCoreException(() -> product.decreaseLikeCount(), "좋아요 갯수는 음수가 될 수 없습니다"); + } + } + } From 787b69b62c27e6b2c396a79a05572e91e13c5762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 14:16:11 +0900 Subject: [PATCH 28/55] =?UTF-8?q?feat=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/like/Like.java | 34 +++++++++++++++++++ .../com/loopers/domain/like/LikeTest.java | 34 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index fb5a4737d..f3f26b4e5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -1,8 +1,42 @@ package com.loopers.domain.like; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + public class Like { private Long id; private Long refProductId; private Long refUserId; + + private Like(Long id, Long refProductId, Long refUserId) { + this.id = id; + this.refProductId = refProductId; + this.refUserId = refUserId; + } + + public static Like create(Long id, Long refProductId, Long refUserId) { + validateRefId(refProductId, refUserId); + + return new Like(id, refProductId, refUserId); + } + + private static void validateRefId(Long refProductId, Long refUserId) { + if (refProductId == null || refProductId < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품FK는 null이거나 음수가 될 수 없습니다"); + } + + if (refUserId == null || refUserId < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저FK는 null이거나 음수가 될 수 없습니다"); + } + } + + public Long getRefProductId() { + return refProductId; + } + + public Long getRefUserId() { + return refUserId; + } + } 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..efa442be2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,34 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class LikeTest { + + @DisplayName("좋아요의 상품 정보가 유효하지 않으면, 예외를 던진다") + @Test + void fail_when_invalid_ref_product_id() { + Long id = 100L; + Long refProductId = -100L; + Long refUserId = 100L; + + assertThatThrownBy(() -> Like.create(id, refProductId, refUserId)) + .isInstanceOf(CoreException.class) + .hasMessage("상품FK는 null이거나 음수가 될 수 없습니다"); + } + + @DisplayName("좋아요의 유저 정보가 유효하지 않으면, 예외를 던진다") + @Test + void fail_when_invalid_ref_user_id() { + Long id = 100L; + Long refProductId = 100L; + Long refUserId = -100L; + + assertThatThrownBy(() -> Like.create(id, refProductId, refUserId)) + .isInstanceOf(CoreException.class) + .hasMessage("유저FK는 null이거나 음수가 될 수 없습니다"); + } +} From 3530cdad52cd7f1a98294651e9060bc78fbc763a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 14:17:28 +0900 Subject: [PATCH 29/55] =?UTF-8?q?feat=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EC=A6=9D=EA=B0=90=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/like/LikeRepository.java | 9 ++ .../com/loopers/domain/like/LikeService.java | 34 +++++ .../domain/product/ProductService.java | 12 ++ .../infrastructure/like/LikeEntity.java | 20 +++ .../like/LikeJpaRepository.java | 4 + .../like/LikeRepositoryImpl.java | 25 ++++ .../domain/product/ProductServiceTest.java | 116 ++++++++++-------- 7 files changed, 170 insertions(+), 50 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java 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 a18ad282c..dfc960690 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,5 +1,14 @@ package com.loopers.domain.like; +import java.util.Optional; + public interface LikeRepository { + boolean existByUniqueId(Long productId, Long userId); + + Optional findByUniqueId(Long productId, 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 new file mode 100644 index 000000000..f47397ebf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,34 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + + public Like like(Long productId, Long userId) { + Like like = Like.create(null, productId, userId); + + return likeRepository.save(like); + } + + public void unlike(Long productId, Long userId) { + Like like = findByUniqueId(productId, userId); + + likeRepository.delete(like); + } + + public boolean isLiked(Long productId, Long userId) { + return likeRepository.existByUniqueId(productId, userId); + } + + public Like findByUniqueId(Long productId, Long userId) { + return likeRepository.findByUniqueId(productId, userId) + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "좋아요 객체를 찾을 수 없습니다")); + } +} 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 c8e760985..d2948cb7b 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 @@ -42,6 +42,18 @@ public List createProducts(Map createProduc return createdProducts; } + public void increaseLikeCount(Long productId) { + Product product = findById(productId); + product.increaseLikeCount(); + productRepository.update(product); + } + + public void decreaseLikeCount(Long productId) { + Product product = findById(productId); + product.decreaseLikeCount(); + productRepository.update(product); + } + public void decreaseStock(Long productId, Integer decreaseStock) { Product product = findById(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java index b3e7bdf1e..c2b9616eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -1,13 +1,16 @@ package com.loopers.infrastructure.like; import com.loopers.domain.BaseEntity; +import com.loopers.domain.like.Like; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; @Entity @Table(name = "like") +@NoArgsConstructor public class LikeEntity extends BaseEntity { @Comment("상품 id (ref)") @@ -18,6 +21,15 @@ public class LikeEntity extends BaseEntity { @Column(name = "ref_user_id", nullable = false) private Long refUserId; + public LikeEntity(Like like) { + this.refProductId = like.getRefProductId(); + this.refUserId = like.getRefUserId(); + } + + public static LikeEntity toEntity(Like like) { + return new LikeEntity(like); + } + public Long getRefProductId() { return refProductId; } @@ -25,4 +37,12 @@ public Long getRefProductId() { public Long getRefUserId() { return refUserId; } + + public static Like toDomain(LikeEntity likeEntity) { + return Like.create( + likeEntity.getId(), + likeEntity.getRefProductId(), + likeEntity.getRefUserId() + ); + } } 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 4b1fe3d9e..7de3c68fe 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 @@ -1,7 +1,11 @@ package com.loopers.infrastructure.like; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface LikeJpaRepository extends JpaRepository { + boolean existsByRefProductIdAndRefUserId(Long productId, Long userId); + + Optional findByRefProductIdAndRefUserId(Long productId, 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 8ce3498c5..8c7457ff7 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 @@ -1,6 +1,8 @@ package com.loopers.infrastructure.like; +import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeRepository; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -8,5 +10,28 @@ @Component public class LikeRepositoryImpl implements LikeRepository { + private final LikeJpaRepository likeJpaRepository; + @Override + public Like save(Like like) { + LikeEntity likeEntity = LikeEntity.toEntity(like); + + return LikeEntity.toDomain(likeJpaRepository.save(likeEntity)); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(LikeEntity.toEntity(like)); + } + + @Override + public boolean existByUniqueId(Long productId, Long userId) { + return likeJpaRepository.existsByRefProductIdAndRefUserId(productId, userId); + } + + @Override + public Optional findByUniqueId(Long productId, Long userId) { + return likeJpaRepository.findByRefProductIdAndRefUserId(productId, userId) + .map(LikeEntity::toDomain); + } } 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 94f9018fb..5830fd2b7 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 @@ -10,6 +10,7 @@ import java.util.Map; import java.util.Optional; 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.junit.jupiter.params.ParameterizedTest; @@ -30,62 +31,77 @@ class ProductServiceTest { @Mock private ProductRepository productRepository; - @ParameterizedTest - @NullAndEmptySource - @DisplayName("상품 생성 요청이 null이거나 비어있으면, 예외를 던진다") - void fail_createProducts_when_command_is_null_or_empty(Map command) { - assertThatThrownBy(() -> productService.createProducts(command)) - .isInstanceOf(CoreException.class) - .hasMessage("상품 생성 요청은 필수입니다"); + @Nested + @DisplayName("상품 생성") + class CreateProducts { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 생성 요청이 null이거나 비어있으면, 예외를 던진다") + void fail_when_command_is_null_or_empty(Map command) { + assertThatThrownBy(() -> productService.createProducts(command)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 생성 요청은 필수입니다"); + } + + @Test + @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") + void fail_when_brand_not_found() { + Long brandId = 999L; + CreateProductRequest request = new CreateProductRequest("product1", 100000, 10); + Map command = Map.of(brandId, request); + + willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) + .given(brandValidator).validateExists(brandId); + + assertThatThrownBy(() -> productService.createProducts(command)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } } - @Test - @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") - void fail_createProducts_when_brand_not_found() { - Long brandId = 999L; - CreateProductRequest request = new CreateProductRequest("product1", 100000, 10); - Map command = Map.of(brandId, request); + @Nested + @DisplayName("재고 차감") + class DecreaseStock { - willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) - .given(brandValidator).validateExists(brandId); + @Test + @DisplayName("상품이 존재하지 않으면, 예외를 던진다") + void fail_when_product_not_found() { + Long productId = 10101L; + Integer decreaseStock = 100; - assertThatThrownBy(() -> productService.createProducts(command)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드를 찾을 수 없습니다"); - } - - @Test - @DisplayName("상품 수량 감소시 상품이 존재하지 않으면, 예외를 던진다") - void fail_decreaseStock_when_product_not_found() { - Long productId = 10101L; - Integer decreaseStock = 100; - - given(productRepository.findById(productId)).willReturn(Optional.empty()); - - assertThatThrownBy(() -> productService.decreaseStock(productId, decreaseStock)) - .isInstanceOf(CoreException.class) - .hasMessage("상품을 찾을 수 없습니다"); - } - - @Test - @DisplayName("상품 목록 페이징 조회시 브랜드가 존재하지 않으면, 예외를 던진다") - void fail_getProducts_when_brand_not_found() { - Long brandId = 999L; - ProductSearchCondition condition = ProductSearchCondition.of(brandId, ProductSortType.LATEST, 0, 10); - - willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) - .given(brandValidator).validateExists(brandId); + given(productRepository.findById(productId)).willReturn(Optional.empty()); - assertThatThrownBy(() -> productService.getProducts(condition)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드를 찾을 수 없습니다"); + assertThatThrownBy(() -> productService.decreaseStock(productId, decreaseStock)) + .isInstanceOf(CoreException.class) + .hasMessage("상품을 찾을 수 없습니다"); + } } - @Test - @DisplayName("상품 목록 조회시 검색 조건이 null이면, 예외를 던진다") - void fail_getProducts_when_condition_is_null() { - assertThatThrownBy(() -> productService.getProducts(null)) - .isInstanceOf(CoreException.class) - .hasMessage("검색 조건은 필수입니다"); + @Nested + @DisplayName("상품 목록 조회") + class GetProducts { + + @Test + @DisplayName("브랜드가 존재하지 않으면, 예외를 던진다") + void fail_when_brand_not_found() { + Long brandId = 999L; + ProductSearchCondition condition = ProductSearchCondition.of(brandId, ProductSortType.LATEST, 0, 10); + + willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) + .given(brandValidator).validateExists(brandId); + + assertThatThrownBy(() -> productService.getProducts(condition)) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드를 찾을 수 없습니다"); + } + + @Test + @DisplayName("검색 조건이 null이면, 예외를 던진다") + void fail_when_condition_is_null() { + assertThatThrownBy(() -> productService.getProducts(null)) + .isInstanceOf(CoreException.class) + .hasMessage("검색 조건은 필수입니다"); + } } } From 3211c095f69b27ad3c879aa96060b8816f816afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 14:55:43 +0900 Subject: [PATCH 30/55] =?UTF-8?q?feat=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=A9=B1=EB=93=B1=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=98=88=EC=99=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/like/LikeService.java | 4 +++ .../loopers/domain/like/LikeServiceTest.java | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java 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 f47397ebf..16b5134ff 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 @@ -12,6 +12,10 @@ public class LikeService { private final LikeRepository likeRepository; public Like like(Long productId, Long userId) { + if (likeRepository.existByUniqueId(productId, userId)) { + throw new CoreException(ErrorType.BAD_REQUEST, "이미 좋아요를 누른 상품입니다"); + } + Like like = Like.create(null, productId, userId); return likeRepository.save(like); 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..233e66ee4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -0,0 +1,35 @@ +package com.loopers.domain.like; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LikeServiceTest { + + @InjectMocks + private LikeService likeService; + + @Mock + private LikeRepository likeRepository; + + @DisplayName("이미 좋아요를 누른 상품이면, 예외를 던진다") + @Test + void fail_like_when_already_liked() { + Long productId = 1L; + Long userId = 2L; + + given(likeRepository.existByUniqueId(productId, userId)).willReturn(true); + + assertThatThrownBy(() -> likeService.like(productId, userId)) + .isInstanceOf(CoreException.class) + .hasMessage("이미 좋아요를 누른 상품입니다"); + } +} From 76c3d167ec25ca485cc84d4ed3b8614dc3a8b5c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 14:56:11 +0900 Subject: [PATCH 31/55] =?UTF-8?q?feat=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20LikeFacade=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 26 +++++++++++++++++++ .../java/com/loopers/domain/brand/Brand.java | 2 +- .../java/com/loopers/domain/like/Like.java | 3 +++ .../infrastructure/brand/BrandEntity.java | 2 +- .../infrastructure/like/LikeEntity.java | 3 +++ 5 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..cf2d6331a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,26 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + + public void toggleLike(Long productId, Long userId) { + if (likeService.isLiked(productId, userId)) { + likeService.unlike(productId, userId); + productService.decreaseLikeCount(productId); + } else { + likeService.like(productId, userId); + productService.increaseLikeCount(productId); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index e7937465a..ed4cebfe4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -5,7 +5,7 @@ import java.time.ZonedDateTime; /** - * Brand 도메인 + * 브랜드 도메인 객체 */ public class 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 index f3f26b4e5..f1c3ea25b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -3,6 +3,9 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +/** + * 좋아요 도메인 객체 + */ public class Like { private Long id; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java index c9930c2de..180b31d2e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java @@ -9,7 +9,7 @@ import org.hibernate.annotations.Comment; /** - * Brand DB 엔티티 + * 브랜드 DB 엔티티 */ @Entity @Table(name = "brand") diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java index c2b9616eb..7178d3f50 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -8,6 +8,9 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; +/** + * 좋아요 DB 엔티티 + */ @Entity @Table(name = "like") @NoArgsConstructor From 827fe4ce719be619e3db48500046765e96fb8d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 15:55:47 +0900 Subject: [PATCH 32/55] =?UTF-8?q?test=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20null=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/like/LikeTest.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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 index efa442be2..5949a2dfd 100644 --- 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 @@ -4,15 +4,18 @@ import com.loopers.support.error.CoreException; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; public class LikeTest { @DisplayName("좋아요의 상품 정보가 유효하지 않으면, 예외를 던진다") - @Test - void fail_when_invalid_ref_product_id() { + @ParameterizedTest + @NullSource + @ValueSource(longs = {-100L}) + void fail_when_invalid_ref_product_id(Long refProductId) { Long id = 100L; - Long refProductId = -100L; Long refUserId = 100L; assertThatThrownBy(() -> Like.create(id, refProductId, refUserId)) @@ -21,11 +24,12 @@ void fail_when_invalid_ref_product_id() { } @DisplayName("좋아요의 유저 정보가 유효하지 않으면, 예외를 던진다") - @Test - void fail_when_invalid_ref_user_id() { + @ParameterizedTest + @NullSource + @ValueSource(longs = {-100L}) + void fail_when_invalid_ref_user_id(Long refUserId) { Long id = 100L; Long refProductId = 100L; - Long refUserId = -100L; assertThatThrownBy(() -> Like.create(id, refProductId, refUserId)) .isInstanceOf(CoreException.class) From 177fff1f84a11640eac1cae96fb906b3e362d23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 16:26:20 +0900 Subject: [PATCH 33/55] =?UTF-8?q?refactor=20:=20Product=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=98=EB=8D=98=20BrandValidator=EB=A5=BC?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 34 ++++++++++++++++++ .../loopers/domain/brand/BrandValidator.java | 19 ---------- .../com/loopers/domain/product/Product.java | 2 +- .../domain/product/ProductService.java | 10 ------ .../infrastructure/like/LikeEntity.java | 2 +- .../domain/brand/BrandValidatorTest.java | 34 ------------------ .../domain/product/ProductServiceTest.java | 35 ------------------- 7 files changed, 36 insertions(+), 100 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..f79de2efe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,34 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.CreateProductRequest; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductSearchCondition; +import com.loopers.domain.product.ProductService; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ProductFacade { + + private final BrandService brandService; + private final ProductService productService; + + public List createProducts(Map command) { + command.keySet().forEach(brandService::findById); + return productService.createProducts(command); + } + + @Transactional(readOnly = true) + public List getProducts(ProductSearchCondition condition) { + if (condition.hasBrandId()) { + brandService.findById(condition.brandId()); + } + return productService.getProducts(condition); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java deleted file mode 100644 index 7f8e49c34..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandValidator.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.loopers.domain.brand; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class BrandValidator { - - private final BrandRepository brandRepository; - - public void validateExists(Long id) { - if (!brandRepository.existsById(id)) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다"); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 1b2abd608..6ef200699 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -25,7 +25,7 @@ private Product(Long id, String name, Long refBrandId, Integer price, Integer st this.likeCount = likeCount; } - public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { + public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { validateName(name); validateBrandId(refBrandId); validatePrice(price); 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 d2948cb7b..53af65086 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,5 @@ package com.loopers.domain.product; -import com.loopers.domain.brand.BrandValidator; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import java.util.ArrayList; @@ -15,15 +14,11 @@ public class ProductService { private final ProductRepository productRepository; - private final BrandValidator brandValidator; - public List createProducts(Map createProductsCommand) { if (createProductsCommand == null || createProductsCommand.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 생성 요청은 필수입니다"); } - createProductsCommand.keySet().forEach(brandValidator::validateExists); - List createdProducts = new ArrayList<>(); createProductsCommand.forEach((brandId, request) -> { @@ -56,9 +51,7 @@ public void decreaseLikeCount(Long productId) { public void decreaseStock(Long productId, Integer decreaseStock) { Product product = findById(productId); - product.decreaseStock(decreaseStock); - productRepository.update(product); } @@ -71,9 +64,6 @@ public List getProducts(ProductSearchCondition condition) { if (condition == null) { throw new CoreException(ErrorType.BAD_REQUEST, "검색 조건은 필수입니다"); } - if (condition.hasBrandId()) { - brandValidator.validateExists(condition.brandId()); - } return productRepository.findAll(condition); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java index 7178d3f50..011a6d3da 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -12,7 +12,7 @@ * 좋아요 DB 엔티티 */ @Entity -@Table(name = "like") +@Table(name = "likes") @NoArgsConstructor public class LikeEntity extends BaseEntity { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java deleted file mode 100644 index 996509ebc..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandValidatorTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.loopers.domain.brand; - -import static org.mockito.BDDMockito.given; - -import com.loopers.support.error.CoreException; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class BrandValidatorTest { - - @InjectMocks - private BrandValidator brandValidator; - - @Mock - private BrandRepository brandRepository; - - @Test - @DisplayName("브랜드가 존재하지 않으면 예외를 던진다") - void fail_validateExists_not_found() { - Long id = 10L; - - given(brandRepository.existsById(id)).willReturn(false); - - Assertions.assertThatThrownBy(() -> brandValidator.validateExists(id)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드를 찾을 수 없습니다"); - } -} 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 5830fd2b7..d4c12f3ae 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 @@ -2,11 +2,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; -import com.loopers.domain.brand.BrandValidator; import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -25,9 +22,6 @@ class ProductServiceTest { @InjectMocks private ProductService productService; - @Mock - private BrandValidator brandValidator; - @Mock private ProductRepository productRepository; @@ -43,21 +37,6 @@ void fail_when_command_is_null_or_empty(Map command) .isInstanceOf(CoreException.class) .hasMessage("상품 생성 요청은 필수입니다"); } - - @Test - @DisplayName("상품 생성시 브랜드가 존재하지 않으면 예외를 던진다") - void fail_when_brand_not_found() { - Long brandId = 999L; - CreateProductRequest request = new CreateProductRequest("product1", 100000, 10); - Map command = Map.of(brandId, request); - - willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) - .given(brandValidator).validateExists(brandId); - - assertThatThrownBy(() -> productService.createProducts(command)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드를 찾을 수 없습니다"); - } } @Nested @@ -82,20 +61,6 @@ void fail_when_product_not_found() { @DisplayName("상품 목록 조회") class GetProducts { - @Test - @DisplayName("브랜드가 존재하지 않으면, 예외를 던진다") - void fail_when_brand_not_found() { - Long brandId = 999L; - ProductSearchCondition condition = ProductSearchCondition.of(brandId, ProductSortType.LATEST, 0, 10); - - willThrow(new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")) - .given(brandValidator).validateExists(brandId); - - assertThatThrownBy(() -> productService.getProducts(condition)) - .isInstanceOf(CoreException.class) - .hasMessage("브랜드를 찾을 수 없습니다"); - } - @Test @DisplayName("검색 조건이 null이면, 예외를 던진다") void fail_when_condition_is_null() { From 29400a4b44f6f1ac0fdc9165fc434fcb8408f10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 16:35:49 +0900 Subject: [PATCH 34/55] =?UTF-8?q?feat=20:=20=EC=83=81=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20facade=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?&=20=EC=9D=91=EB=8B=B5=20dto=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 제# 다음 라인에 작성하세요 --- .../application/product/ProductFacade.java | 8 +++++ .../application/product/ProductInfo.java | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index f79de2efe..5e8507002 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 @@ -1,5 +1,6 @@ package com.loopers.application.product; +import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.CreateProductRequest; import com.loopers.domain.product.Product; @@ -24,6 +25,13 @@ public List createProducts(Map command) { return productService.createProducts(command); } + @Transactional(readOnly = true) + public ProductInfo getProduct(Long productId) { + Product product = productService.findById(productId); + Brand brand = brandService.findById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + } + @Transactional(readOnly = true) public List getProducts(ProductSearchCondition condition) { if (condition.hasBrandId()) { 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..ae2575de9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +public record ProductInfo( + Long id, + String name, + Integer price, + Integer stock, + Integer likeCount, + Long brandId, + String brandName, + String brandDescription +) { + + public static ProductInfo of(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getName(), + product.getPrice(), + product.getStock(), + product.getLikeCount(), + brand.getId(), + brand.getName(), + brand.getDescription() + ); + } +} From 76713b8817fcffddd1304914ff179330fb4b6f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 23 Feb 2026 16:38:25 +0900 Subject: [PATCH 35/55] =?UTF-8?q?test=20:=20=EB=B8=8C=EB=9E=9C=EB=93=9C=20?= =?UTF-8?q?update=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/BrandTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 90a6acde9..17d80b504 100644 --- 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 @@ -36,4 +36,16 @@ void fail_create_brand_with_invalid_name(String name) { .isInstanceOf(CoreException.class) .hasMessage("브랜드 이름은 필수 입니다"); } + + @DisplayName("브랜드 이름이 유효하지 않다면, 수정시 예외를 던진다") + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", " "}) + void fail_update_brand_with_invalid_name(String name) { + Brand brand = Brand.create(null, "brand1", "description1"); + + Assertions.assertThatThrownBy(() -> brand.update(name, "description2")) + .isInstanceOf(CoreException.class) + .hasMessage("브랜드 이름은 필수 입니다"); + } } From a235eb04bdc597322e1733cd7f3beb9477116745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 24 Feb 2026 16:40:12 +0900 Subject: [PATCH 36/55] =?UTF-8?q?feat=20:=20(=EC=98=A4=EB=8D=94=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20&=20DB=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/order/Order.java | 79 +++++++++++++++++ .../com/loopers/domain/order/OrderStatus.java | 13 +++ .../infrastructure/order/OrderEntity.java | 85 +++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..62e7a850d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,79 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.ZonedDateTime; + +/** + * Order 도메인 + */ +public class Order { + + private final Long id; + private final Long refUserId; + + private OrderStatus status; + private Integer totalPrice; + private ZonedDateTime orderDt; + + private Order(Long id, Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { + this.id = id; + this.refUserId = refUserId; + this.status = status; + this.totalPrice = totalPrice; + this.orderDt = orderDt; + } + + public static Order create(Long id, Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { + validateRefUserId(refUserId); + validateTotalPrice(totalPrice); + validateOrderDt(orderDt); + + return new Order(id, refUserId, status, totalPrice, orderDt); + } + + private static void validateRefUserId(Long refUserId) { + if (refUserId == null || refUserId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저FK는 null이거나 0이하가 될 수 없습니다"); + } + } + + private static void validateTotalPrice(Integer totalPrice) { + if (totalPrice == null || totalPrice < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 주문 금액은 null이거나 음수가 될 수 없습니다"); + } + } + + private static void validateOrderDt(ZonedDateTime orderDt) { + if (orderDt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 일시는 필수입니다"); + } + } + + public void cancel() { + if (this.status != OrderStatus.ORDERED) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 완료 상태에서만 취소할 수 있습니다"); + } + this.status = OrderStatus.CANCELLED; + } + + public Long getId() { + return id; + } + + public Long getRefUserId() { + return refUserId; + } + + public OrderStatus getStatus() { + return status; + } + + public Integer getTotalPrice() { + return totalPrice; + } + + public ZonedDateTime getOrderDt() { + return orderDt; + } +} 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..f1f7a0778 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + + ORDERED("주문 완료"), // 주문 완료 + CANCELLED("주문 취소"); // 주문 취소 + + private final String message; + + OrderStatus(String message) { + this.message = message; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java new file mode 100644 index 000000000..12bcdbfaa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -0,0 +1,85 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * Order DB 엔티티 + */ +@Entity +@Table(name = "orders") +@NoArgsConstructor +public class OrderEntity extends BaseEntity { + + @Comment("유저 id (ref)") + @Column(name = "ref_user_id", nullable = false, updatable = false) + private Long refUserId; + + @Comment("주문 상태") + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OrderStatus status; + + @Comment("총 주문 금액") + @Column(name = "total_price", nullable = false) + private Integer totalPrice; + + @Comment("주문 일시") + @Column(name = "order_dt", nullable = false, updatable = false) + private ZonedDateTime orderDt; + + private OrderEntity(Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { + this.refUserId = refUserId; + this.status = status; + this.totalPrice = totalPrice; + this.orderDt = orderDt; + } + + public static OrderEntity create(Order order) { + return new OrderEntity( + order.getRefUserId(), + order.getStatus(), + order.getTotalPrice(), + order.getOrderDt() + ); + } + + public static Order toDomain(OrderEntity entity) { + return Order.create( + entity.getId(), + entity.refUserId, + entity.status, + entity.totalPrice, + entity.orderDt + ); + } + + public void updateStatus(OrderStatus status) { + this.status = status; + } + + public Long getRefUserId() { + return refUserId; + } + + public OrderStatus getStatus() { + return status; + } + + public Integer getTotalPrice() { + return totalPrice; + } + + public ZonedDateTime getOrderDt() { + return orderDt; + } +} From b3393beb97e3e11349bc07785abb98570750741d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 24 Feb 2026 16:40:57 +0900 Subject: [PATCH 37/55] =?UTF-8?q?test=20:=20(=EC=98=A4=EB=8D=94=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=98=A4=EB=8D=94=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/order/OrderTest.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java 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..45e0da989 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,101 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderTest { + + private static final Long DEFAULT_USER_ID = 1L; + private static final Integer DEFAULT_TOTAL_PRICE = 10000; + private static final ZonedDateTime DEFAULT_ORDER_DT = ZonedDateTime.now(); + + private static Order createOrder(Long refUserId, Integer totalPrice, ZonedDateTime orderDt) { + return Order.create(null, refUserId, OrderStatus.ORDERED, totalPrice, orderDt); + } + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("주문 생성") + class Create { + + @Test + @DisplayName("주문 생성에 성공한다") + void success_create_order() { + Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); + + assertThat(order.getRefUserId()).isEqualTo(DEFAULT_USER_ID); + assertThat(order.getTotalPrice()).isEqualTo(DEFAULT_TOTAL_PRICE); + assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED); + assertThat(order.getOrderDt()).isEqualTo(DEFAULT_ORDER_DT); + } + + @DisplayName("유저 정보가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_user_id(Long refUserId) { + assertCoreException( + () -> createOrder(refUserId, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT), + "유저FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @DisplayName("총 주문 금액이 null이거나 음수이면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-1, -1000}) + void fail_when_invalid_total_price(Integer totalPrice) { + assertCoreException( + () -> createOrder(DEFAULT_USER_ID, totalPrice, DEFAULT_ORDER_DT), + "총 주문 금액은 null이거나 음수가 될 수 없습니다" + ); + } + + @Test + @DisplayName("주문 일시가 null이면, 생성시 예외를 던진다") + void fail_when_order_dt_is_null() { + assertCoreException( + () -> createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, null), + "주문 일시는 필수입니다" + ); + } + } + + @Nested + @DisplayName("주문 취소") + class Cancel { + + @Test + @DisplayName("주문 완료 상태에서 취소하면, 상태가 취소로 변경된다") + void success_cancel_order() { + Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + @DisplayName("이미 취소된 주문을 취소하면, 예외를 던진다") + void fail_when_already_cancelled() { + Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); + order.cancel(); + + assertCoreException(() -> order.cancel(), "주문 완료 상태에서만 취소할 수 있습니다"); + } + } +} From fb1d1ed6f0f64c2994d9d7d7d815415ab801e64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 24 Feb 2026 16:56:24 +0900 Subject: [PATCH 38/55] =?UTF-8?q?feat=20:=20(=EC=98=A4=EB=8D=94=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20Order,=20OrderItem=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B0=9D=EC=B2=B4=20=EB=B0=8F=20DB=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loopers/domain/order/OrderItem.java | 43 ++++++++ .../domain/order/OrderStatusHistory.java | 37 +++++++ .../infrastructure/order/OrderItemEntity.java | 76 +++++++++++++ .../order/OrderStatusHistoryEntity.java | 101 ++++++++++++++++++ 4 files changed, 257 insertions(+) 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/OrderStatusHistory.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..397b01a7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,43 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +/** + * OrderItem 도메인 + */ +public record OrderItem(Long id, Long refOrderId, Long refProductId, Integer quantity, Integer price) { + + public static OrderItem create(Long id, Long refOrderId, Long refProductId, Integer quantity, Integer price) { + validateRefOrderId(refOrderId); + validateRefProductId(refProductId); + validateQuantity(quantity); + validatePrice(price); + + return new OrderItem(id, refOrderId, refProductId, quantity, price); + } + + private static void validateRefOrderId(Long refOrderId) { + if (refOrderId == null || refOrderId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문FK는 null이거나 0이하가 될 수 없습니다"); + } + } + + private static void validateRefProductId(Long refProductId) { + if (refProductId == null || refProductId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품FK는 null이거나 0이하가 될 수 없습니다"); + } + } + + private static void validateQuantity(Integer quantity) { + if (quantity == null || quantity <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 양수여야 합니다"); + } + } + + private static void validatePrice(Integer price) { + if (price == null || price < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 null이거나 음수가 될 수 없습니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java new file mode 100644 index 000000000..d16fe82d5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java @@ -0,0 +1,37 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.time.ZonedDateTime; + +/** + * OrderStatusHistory 도메인 + */ +public record OrderStatusHistory(Long id, Long refOrderId, OrderStatus fromStatus, OrderStatus toStatus, ZonedDateTime changedAt) { + + public static OrderStatusHistory create(Long id, Long refOrderId, OrderStatus fromStatus, OrderStatus toStatus, ZonedDateTime changedAt) { + validateRefOrderId(refOrderId); + validateToStatus(toStatus); + validateChangedAt(changedAt); + + return new OrderStatusHistory(id, refOrderId, fromStatus, toStatus, changedAt); + } + + private static void validateRefOrderId(Long refOrderId) { + if (refOrderId == null || refOrderId <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문FK는 null이거나 0이하가 될 수 없습니다"); + } + } + + private static void validateToStatus(OrderStatus toStatus) { + if (toStatus == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "변경 후 상태는 필수입니다"); + } + } + + private static void validateChangedAt(ZonedDateTime changedAt) { + if (changedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상태 변경 일시는 필수입니다"); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java new file mode 100644 index 000000000..48788fb3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -0,0 +1,76 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.order.OrderItem; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * OrderItem DB 엔티티 + */ +@Entity +@Table(name = "order_item") +@NoArgsConstructor +public class OrderItemEntity extends BaseEntity { + + @Comment("주문 id (ref)") + @Column(name = "ref_order_id", nullable = false, updatable = false) + private Long refOrderId; + + @Comment("상품 id (ref)") + @Column(name = "ref_product_id", nullable = false, updatable = false) + private Long refProductId; + + @Comment("주문 수량") + @Column(name = "quantity", nullable = false, updatable = false) + private Integer quantity; + + @Comment("주문 금액") + @Column(name = "price", nullable = false, updatable = false) + private Integer price; + + private OrderItemEntity(Long refOrderId, Long refProductId, Integer quantity, Integer price) { + this.refOrderId = refOrderId; + this.refProductId = refProductId; + this.quantity = quantity; + this.price = price; + } + + public static OrderItemEntity create(OrderItem orderItem) { + return new OrderItemEntity( + orderItem.refOrderId(), + orderItem.refProductId(), + orderItem.quantity(), + orderItem.price() + ); + } + + public static OrderItem toDomain(OrderItemEntity entity) { + return OrderItem.create( + entity.getId(), + entity.refOrderId, + entity.refProductId, + entity.quantity, + entity.price + ); + } + + public Long getRefOrderId() { + return refOrderId; + } + + public Long getRefProductId() { + return refProductId; + } + + public Integer getQuantity() { + return quantity; + } + + public Integer getPrice() { + return price; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java new file mode 100644 index 000000000..2dd3bde5f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java @@ -0,0 +1,101 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.OrderStatusHistory; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +/** + * OrderStatusHistory DB 엔티티 + */ +@Entity +@Table(name = "order_status_history") +@NoArgsConstructor +public class OrderStatusHistoryEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("주문 id (ref)") + @Column(name = "ref_order_id", nullable = false, updatable = false) + private Long refOrderId; + + @Comment("변경 전 상태") + @Enumerated(EnumType.STRING) + @Column(name = "from_status", updatable = false) + private OrderStatus fromStatus; + + @Comment("변경 후 상태") + @Enumerated(EnumType.STRING) + @Column(name = "to_status", nullable = false, updatable = false) + private OrderStatus toStatus; + + @Comment("상태 변경 일시") + @Column(name = "changed_at", nullable = false, updatable = false) + private ZonedDateTime changedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + private OrderStatusHistoryEntity(Long refOrderId, OrderStatus fromStatus, OrderStatus toStatus, ZonedDateTime changedAt) { + this.refOrderId = refOrderId; + this.fromStatus = fromStatus; + this.toStatus = toStatus; + this.changedAt = changedAt; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } + + public static OrderStatusHistoryEntity create(OrderStatusHistory history) { + return new OrderStatusHistoryEntity( + history.refOrderId(), + history.fromStatus(), + history.toStatus(), + history.changedAt() + ); + } + + public static OrderStatusHistory toDomain(OrderStatusHistoryEntity entity) { + return OrderStatusHistory.create( + entity.id, + entity.refOrderId, + entity.fromStatus, + entity.toStatus, + entity.changedAt + ); + } + + public Long getId() { + return id; + } + + public Long getRefOrderId() { + return refOrderId; + } + + public OrderStatus getFromStatus() { + return fromStatus; + } + + public OrderStatus getToStatus() { + return toStatus; + } + + public ZonedDateTime getChangedAt() { + return changedAt; + } +} From 009ae726c62461234ee186227b0a9deeb00dfd72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 24 Feb 2026 17:00:38 +0900 Subject: [PATCH 39/55] =?UTF-8?q?refactor=20:=20=EB=B6=88=EB=B3=80=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0(=3D=ED=95=84=EB=93=9C)=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20db=20=EB=B0=A9=EC=96=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/infrastructure/like/LikeEntity.java | 4 ++-- .../com/loopers/infrastructure/product/ProductEntity.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java index 011a6d3da..da0dd41b5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeEntity.java @@ -17,11 +17,11 @@ public class LikeEntity extends BaseEntity { @Comment("상품 id (ref)") - @Column(name = "ref_product_id", nullable = false) + @Column(name = "ref_product_id", nullable = false, updatable = false) private Long refProductId; @Comment("유저 id (ref)") - @Column(name = "ref_user_id", nullable = false) + @Column(name = "ref_user_id", nullable = false, updatable = false) private Long refUserId; public LikeEntity(Like like) { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 0fdc52841..95b595c72 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -21,7 +21,7 @@ public class ProductEntity extends BaseEntity { private String name; @Comment("브랜드 id (ref)") - @Column(name = "ref_brand_id", nullable = false) + @Column(name = "ref_brand_id", nullable = false, updatable = false) private Long refBrandId; @Comment("현재 판매가") From fa21c2ff923ff9eb4d6c528caf3aa33abcc7a5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 24 Feb 2026 17:18:18 +0900 Subject: [PATCH 40/55] =?UTF-8?q?test=20:=20OrderItem,=20OrderStatusHistor?= =?UTF-8?q?y=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/OrderStatusHistory.java | 16 ++-- .../order/OrderStatusHistoryEntity.java | 55 +++--------- .../loopers/domain/order/OrderItemTest.java | 86 +++++++++++++++++++ .../domain/order/OrderStatusHistoryTest.java | 71 +++++++++++++++ 4 files changed, 177 insertions(+), 51 deletions(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java index d16fe82d5..b4e956cb8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java @@ -7,14 +7,14 @@ /** * OrderStatusHistory 도메인 */ -public record OrderStatusHistory(Long id, Long refOrderId, OrderStatus fromStatus, OrderStatus toStatus, ZonedDateTime changedAt) { +public record OrderStatusHistory(Long id, Long refOrderId, OrderStatus status, ZonedDateTime changedAt) { - public static OrderStatusHistory create(Long id, Long refOrderId, OrderStatus fromStatus, OrderStatus toStatus, ZonedDateTime changedAt) { + public static OrderStatusHistory create(Long id, Long refOrderId, OrderStatus status, ZonedDateTime changedAt) { validateRefOrderId(refOrderId); - validateToStatus(toStatus); + validateStatus(status); validateChangedAt(changedAt); - return new OrderStatusHistory(id, refOrderId, fromStatus, toStatus, changedAt); + return new OrderStatusHistory(id, refOrderId, status, changedAt); } private static void validateRefOrderId(Long refOrderId) { @@ -23,15 +23,15 @@ private static void validateRefOrderId(Long refOrderId) { } } - private static void validateToStatus(OrderStatus toStatus) { - if (toStatus == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "변경 후 상태는 필수입니다"); + private static void validateStatus(OrderStatus status) { + if (status == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상태는 필수입니다"); } } private static void validateChangedAt(ZonedDateTime changedAt) { if (changedAt == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "상태 변경 일시는 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, "주문 상태 변경 일시는 필수입니다"); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java index 2dd3bde5f..6d86a0f3f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java @@ -1,15 +1,12 @@ package com.loopers.infrastructure.order; +import com.loopers.domain.BaseEntity; import com.loopers.domain.order.OrderStatus; import com.loopers.domain.order.OrderStatusHistory; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import java.time.ZonedDateTime; import lombok.NoArgsConstructor; @@ -21,78 +18,50 @@ @Entity @Table(name = "order_status_history") @NoArgsConstructor -public class OrderStatusHistoryEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class OrderStatusHistoryEntity extends BaseEntity { @Comment("주문 id (ref)") @Column(name = "ref_order_id", nullable = false, updatable = false) private Long refOrderId; - @Comment("변경 전 상태") - @Enumerated(EnumType.STRING) - @Column(name = "from_status", updatable = false) - private OrderStatus fromStatus; - - @Comment("변경 후 상태") + @Comment("주문 상태") @Enumerated(EnumType.STRING) - @Column(name = "to_status", nullable = false, updatable = false) - private OrderStatus toStatus; + @Column(name = "status", nullable = false, updatable = false) + private OrderStatus status; @Comment("상태 변경 일시") @Column(name = "changed_at", nullable = false, updatable = false) private ZonedDateTime changedAt; - @Column(name = "created_at", nullable = false, updatable = false) - private ZonedDateTime createdAt; - - private OrderStatusHistoryEntity(Long refOrderId, OrderStatus fromStatus, OrderStatus toStatus, ZonedDateTime changedAt) { + private OrderStatusHistoryEntity(Long refOrderId, OrderStatus status, ZonedDateTime changedAt) { this.refOrderId = refOrderId; - this.fromStatus = fromStatus; - this.toStatus = toStatus; + this.status = status; this.changedAt = changedAt; } - @PrePersist - private void prePersist() { - this.createdAt = ZonedDateTime.now(); - } - public static OrderStatusHistoryEntity create(OrderStatusHistory history) { return new OrderStatusHistoryEntity( history.refOrderId(), - history.fromStatus(), - history.toStatus(), + history.status(), history.changedAt() ); } public static OrderStatusHistory toDomain(OrderStatusHistoryEntity entity) { return OrderStatusHistory.create( - entity.id, + entity.getId(), entity.refOrderId, - entity.fromStatus, - entity.toStatus, + entity.status, entity.changedAt ); } - public Long getId() { - return id; - } - public Long getRefOrderId() { return refOrderId; } - public OrderStatus getFromStatus() { - return fromStatus; - } - - public OrderStatus getToStatus() { - return toStatus; + public OrderStatus getStatus() { + return status; } public ZonedDateTime getChangedAt() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..c88894640 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,86 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderItemTest { + + private static final Long DEFAULT_ORDER_ID = 1L; + private static final Long DEFAULT_PRODUCT_ID = 1L; + private static final Integer DEFAULT_QUANTITY = 2; + private static final Integer DEFAULT_PRICE = 10000; + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("주문 항목 생성") + class Create { + + @Test + @DisplayName("주문 항목 생성에 성공한다") + void success_create_order_item() { + OrderItem orderItem = OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, DEFAULT_PRICE); + + assertThat(orderItem.refOrderId()).isEqualTo(DEFAULT_ORDER_ID); + assertThat(orderItem.refProductId()).isEqualTo(DEFAULT_PRODUCT_ID); + assertThat(orderItem.quantity()).isEqualTo(DEFAULT_QUANTITY); + assertThat(orderItem.price()).isEqualTo(DEFAULT_PRICE); + } + + @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_order_id(Long refOrderId) { + assertCoreException( + () -> OrderItem.create(null, refOrderId, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, DEFAULT_PRICE), + "주문FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @DisplayName("상품FK가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_product_id(Long refProductId) { + assertCoreException( + () -> OrderItem.create(null, DEFAULT_ORDER_ID, refProductId, DEFAULT_QUANTITY, DEFAULT_PRICE), + "상품FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @DisplayName("수량이 null이거나 양수가 아니면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-1, 0}) + void fail_when_invalid_quantity(Integer quantity) { + assertCoreException( + () -> OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, quantity, DEFAULT_PRICE), + "수량은 양수여야 합니다" + ); + } + + @DisplayName("주문 금액이 null이거나 음수이면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(ints = {-1, -1000}) + void fail_when_invalid_price(Integer price) { + assertCoreException( + () -> OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, price), + "주문 금액은 null이거나 음수가 될 수 없습니다" + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java new file mode 100644 index 000000000..6c136d591 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.support.error.CoreException; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderStatusHistoryTest { + + private static final Long DEFAULT_ORDER_ID = 1L; + private static final ZonedDateTime DEFAULT_CHANGED_AT = ZonedDateTime.now(); + + private static void assertCoreException(Runnable runnable, String message) { + assertThatThrownBy(runnable::run) + .isInstanceOf(CoreException.class) + .hasMessage(message); + } + + @Nested + @DisplayName("주문 상태 이력 생성") + class Create { + + @Test + @DisplayName("주문 상태 이력 생성에 성공한다") + void success_create_order_status_history() { + OrderStatusHistory history = OrderStatusHistory.create( + null, DEFAULT_ORDER_ID, OrderStatus.ORDERED, DEFAULT_CHANGED_AT + ); + + assertThat(history.refOrderId()).isEqualTo(DEFAULT_ORDER_ID); + assertThat(history.status()).isEqualTo(OrderStatus.ORDERED); + assertThat(history.changedAt()).isEqualTo(DEFAULT_CHANGED_AT); + } + + @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") + @ParameterizedTest + @NullSource + @ValueSource(longs = {-1L, 0L}) + void fail_when_invalid_ref_order_id(Long refOrderId) { + assertCoreException( + () -> OrderStatusHistory.create(null, refOrderId, OrderStatus.ORDERED, DEFAULT_CHANGED_AT), + "주문FK는 null이거나 0이하가 될 수 없습니다" + ); + } + + @Test + @DisplayName("주문 상태가 null이면, 생성시 예외를 던진다") + void fail_when_status_is_null() { + assertCoreException( + () -> OrderStatusHistory.create(null, DEFAULT_ORDER_ID, null, DEFAULT_CHANGED_AT), + "주문 상태는 필수입니다" + ); + } + + @Test + @DisplayName("주문 상태 변경 일시가 null이면, 생성시 예외를 던진다") + void fail_when_changed_at_is_null() { + assertCoreException( + () -> OrderStatusHistory.create(null, DEFAULT_ORDER_ID, OrderStatus.ORDERED, null), + "주문 상태 변경 일시는 필수입니다" + ); + } + } +} From 0823feef83e08707e0f08c391f07683204997d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 16:51:28 +0900 Subject: [PATCH 41/55] =?UTF-8?q?feat=20:=20(=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20Order,=20OrderItem,=20OrderStatu?= =?UTF-8?q?sHistory=20repository=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/OrderItemRepository.java | 8 ++++++ .../loopers/domain/order/OrderRepository.java | 6 +++++ .../order/OrderStatusHistoryRepository.java | 6 +++++ .../order/OrderItemJpaRepository.java | 7 ++++++ .../order/OrderItemRepositoryImpl.java | 25 +++++++++++++++++++ .../order/OrderJpaRepository.java | 7 ++++++ .../order/OrderRepositoryImpl.java | 19 ++++++++++++++ .../OrderStatusHistoryJpaRepository.java | 7 ++++++ .../OrderStatusHistoryRepositoryImpl.java | 19 ++++++++++++++ 9 files changed, 104 insertions(+) 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/OrderStatusHistoryRepository.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/order/OrderStatusHistoryJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java 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..6de57cb2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,8 @@ +package com.loopers.domain.order; + +import java.util.List; + +public interface OrderItemRepository { + + List saveAll(List orderItems); +} 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..5a83a7f65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public interface OrderRepository { + + Order save(Order order); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryRepository.java new file mode 100644 index 000000000..e2b253732 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.order; + +public interface OrderStatusHistoryRepository { + + OrderStatusHistory save(OrderStatusHistory history); +} 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..fb6f5a4b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderItemJpaRepository extends JpaRepository { + +} 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..cd7dc5078 --- /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 java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public List saveAll(List orderItems) { + List entities = orderItems.stream() + .map(OrderItemEntity::create) + .toList(); + + return orderItemJpaRepository.saveAll(entities).stream() + .map(OrderItemEntity::toDomain) + .toList(); + } +} 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..116da795b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java new file mode 100644 index 000000000..cef1418b3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + OrderEntity entity = OrderEntity.create(order); + return OrderEntity.toDomain(orderJpaRepository.save(entity)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java new file mode 100644 index 000000000..2eb7ab9d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.order; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderStatusHistoryJpaRepository extends JpaRepository { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java new file mode 100644 index 000000000..c7fa8d598 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java @@ -0,0 +1,19 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderStatusHistory; +import com.loopers.domain.order.OrderStatusHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OrderStatusHistoryRepositoryImpl implements OrderStatusHistoryRepository { + + private final OrderStatusHistoryJpaRepository orderStatusHistoryJpaRepository; + + @Override + public OrderStatusHistory save(OrderStatusHistory history) { + OrderStatusHistoryEntity entity = OrderStatusHistoryEntity.create(history); + return OrderStatusHistoryEntity.toDomain(orderStatusHistoryJpaRepository.save(entity)); + } +} From 5484c83adef58dae2bf9853456bec617d8532185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 16:53:31 +0900 Subject: [PATCH 42/55] =?UTF-8?q?feat=20:=20(=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20facade=20=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 44 +++++++++++++++++++ .../loopers/domain/order/OrderService.java | 36 +++++++++++++++ 2 files changed, 80 insertions(+) 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/domain/order/OrderService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..be2474983 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,44 @@ +package com.loopers.application.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.UserService; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class OrderFacade { + + private final ProductService productService; + private final UserService userService; + private final OrderService orderService; + + public void order(Long userId, Map productQuantities) { + Long validatedUserId = userService.findById(userId).getId(); + + Map productWithQuantities = new java.util.LinkedHashMap<>(); + productQuantities.forEach((productId, quantity) -> + productWithQuantities.put(productService.findById(productId), quantity)); + + productWithQuantities.forEach((product, quantity) -> + productService.decreaseStock(product.getId(), quantity)); + + int totalPrice = productWithQuantities.entrySet().stream() + .mapToInt(e -> e.getKey().getPrice() * e.getValue()) + .sum(); + + var order = orderService.createOrder(validatedUserId, totalPrice); + + List orderItems = productWithQuantities.entrySet().stream() + .map(e -> OrderItem.create(null, order.getId(), e.getKey().getId(), e.getValue(), e.getKey().getPrice())) + .toList(); + orderService.createOrderItems(order.getId(), 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 new file mode 100644 index 000000000..b0fca4b05 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,36 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + private final OrderStatusHistoryRepository orderStatusHistoryRepository; + + public Order createOrder(Long refUserId, Integer totalPrice) { + Order order = Order.create(null, refUserId, OrderStatus.ORDERED, totalPrice, ZonedDateTime.now()); + Order savedOrder = orderRepository.save(order); + + recordHistory(savedOrder.getId(), OrderStatus.ORDERED); + + return savedOrder; + } + + public List createOrderItems(Long orderId, List orderItems) { + List items = orderItems.stream() + .map(item -> OrderItem.create(null, orderId, item.refProductId(), item.quantity(), item.price())) + .toList(); + return orderItemRepository.saveAll(items); + } + + private void recordHistory(Long orderId, OrderStatus status) { + OrderStatusHistory history = OrderStatusHistory.create(null, orderId, status, ZonedDateTime.now()); + orderStatusHistoryRepository.save(history); + } +} From 3e925dfc094114b7b3373548c52fba6db0d6ff99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 20:59:36 +0900 Subject: [PATCH 43/55] =?UTF-8?q?fix=20:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/order/OrderFacade.java | 16 +++++++++++++--- .../domain/product/ProductRepository.java | 2 ++ .../loopers/domain/product/ProductService.java | 11 +++++++++++ .../product/ProductRepositoryImpl.java | 7 +++++++ 4 files changed, 33 insertions(+), 3 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 be2474983..89060fdfd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -5,8 +5,11 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.UserService; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,9 +26,16 @@ public class OrderFacade { public void order(Long userId, Map productQuantities) { Long validatedUserId = userService.findById(userId).getId(); - Map productWithQuantities = new java.util.LinkedHashMap<>(); - productQuantities.forEach((productId, quantity) -> - productWithQuantities.put(productService.findById(productId), quantity)); + List productIds = new ArrayList<>(productQuantities.keySet()); + List products = productService.findByIds(productIds); + + Map productWithQuantities = products.stream() + .collect(Collectors.toMap( + product -> product, + product -> productQuantities.get(product.getId()), + (a, b) -> a, + LinkedHashMap::new + )); productWithQuantities.forEach((product, quantity) -> productService.decreaseStock(product.getId(), quantity)); 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 a98f9e2a3..77a427cc0 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 @@ -9,6 +9,8 @@ public interface ProductRepository { Optional findById(Long id); + List findByIds(List ids); + Product update(Product product); List findAll(ProductSearchCondition condition); 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 53af65086..761006b2b 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 @@ -60,6 +60,17 @@ public Product findById(Long id) { .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "상품을 찾을 수 없습니다")); } + public List findByIds(List ids) { + if (ids == null || ids.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID 목록은 필수입니다"); + } + List products = productRepository.findByIds(ids); + if (products.size() != ids.size()) { + throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않는 상품이 포함되어 있습니다"); + } + return products; + } + public List getProducts(ProductSearchCondition condition) { if (condition == null) { throw new CoreException(ErrorType.BAD_REQUEST, "검색 조건은 필수입니다"); 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 5c4ceabaa..79a2c983a 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 @@ -35,6 +35,13 @@ public Optional findById(Long id) { .map(ProductEntity::toDomain); } + @Override + public List findByIds(List ids) { + return productJpaRepository.findAllById(ids).stream() + .map(ProductEntity::toDomain) + .toList(); + } + @Override public Product update(Product product) { ProductEntity productEntity = productJpaRepository.findById(product.getId()) From 44c263a21d8900a08c8b9121cc8158973411edfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 21:50:19 +0900 Subject: [PATCH 44/55] =?UTF-8?q?rename=20:=20=EC=9D=98=EB=AF=B8=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EA=B2=8C=20find=20get=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 2 +- .../application/product/ProductFacade.java | 10 +++---- .../loopers/domain/brand/BrandService.java | 4 +-- .../domain/product/ProductService.java | 12 ++++----- .../domain/brand/BrandServiceTest.java | 27 ++----------------- 5 files changed, 16 insertions(+), 39 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 89060fdfd..633c8fbbc 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 @@ -27,7 +27,7 @@ public void order(Long userId, Map productQuantities) { Long validatedUserId = userService.findById(userId).getId(); List productIds = new ArrayList<>(productQuantities.keySet()); - List products = productService.findByIds(productIds); + List products = productService.getByIds(productIds); Map productWithQuantities = products.stream() .collect(Collectors.toMap( 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 5e8507002..64738e4d6 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 @@ -21,22 +21,22 @@ public class ProductFacade { private final ProductService productService; public List createProducts(Map command) { - command.keySet().forEach(brandService::findById); + command.keySet().forEach(brandService::getById); return productService.createProducts(command); } @Transactional(readOnly = true) public ProductInfo getProduct(Long productId) { - Product product = productService.findById(productId); - Brand brand = brandService.findById(product.getRefBrandId()); + Product product = productService.getById(productId); + Brand brand = brandService.getById(product.getRefBrandId()); return ProductInfo.of(product, brand); } @Transactional(readOnly = true) public List getProducts(ProductSearchCondition condition) { if (condition.hasBrandId()) { - brandService.findById(condition.brandId()); + brandService.getById(condition.brandId()); } - return productService.getProducts(condition); + return productService.findProducts(condition); } } 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 8d102d2a2..ec7e4e484 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 @@ -17,12 +17,12 @@ public Brand create(String name, String description) { } public Brand update(Long id, String name, String description) { - Brand brand = findById(id); + Brand brand = getById(id); brand.update(name, description); return brandRepository.update(brand); } - public Brand findById(Long id) { + public Brand getById(Long id) { return brandRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")); } 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 761006b2b..5dd2f3875 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 @@ -38,29 +38,29 @@ public List createProducts(Map createProduc } public void increaseLikeCount(Long productId) { - Product product = findById(productId); + Product product = getById(productId); product.increaseLikeCount(); productRepository.update(product); } public void decreaseLikeCount(Long productId) { - Product product = findById(productId); + Product product = getById(productId); product.decreaseLikeCount(); productRepository.update(product); } public void decreaseStock(Long productId, Integer decreaseStock) { - Product product = findById(productId); + Product product = getById(productId); product.decreaseStock(decreaseStock); productRepository.update(product); } - public Product findById(Long id) { + public Product getById(Long id) { return productRepository.findById(id) .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "상품을 찾을 수 없습니다")); } - public List findByIds(List ids) { + public List getByIds(List ids) { if (ids == null || ids.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID 목록은 필수입니다"); } @@ -71,7 +71,7 @@ public List findByIds(List ids) { return products; } - public List getProducts(ProductSearchCondition condition) { + public List findProducts(ProductSearchCondition condition) { if (condition == null) { throw new CoreException(ErrorType.BAD_REQUEST, "검색 조건은 필수입니다"); } 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 index aaa3a2166..389aa8577 100644 --- 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 @@ -37,36 +37,13 @@ void fail_modify_not_found() { @Test @DisplayName("id로 브랜드를 조회할 때 브랜드가 존재하지않으면 예외를 던진다") - void fail_findById_not_found_brand() { + void fail_getById_not_found_brand() { Long id = 10L; given(brandRepository.findById(id)).willReturn(Optional.empty()); - Assertions.assertThatThrownBy(() -> brandService.findById(id)) + Assertions.assertThatThrownBy(() -> brandService.getById(id)) .isInstanceOf(CoreException.class) .hasMessage("브랜드를 찾을 수 없습니다"); } - - -// @Test -// void check_default_return() { -// Long id = 10L; -// -// Optional result = brandRepository.findById(id); -// -// System.out.println("result: " + result); -// System.out.println("isPresent: " + result.isPresent()); -// } - -// @Test -// @DisplayName("BrandEntity를 생성할 때 ID 초기값을 확인한다") -// void check_entity_initial_id() { -// Brand brand = Brand.create(null, "테스트", "설명"); -// -// BrandEntity entity = BrandEntity.create(brand); -// -// System.out.println("생성된 엔티티의 ID: " + entity.getId()); -// -// Assertions.assertThat(entity.getId()).isEqualTo(null); -// } } From 908b09f9ed881bad9c7fc0b1266bdf811c86fcf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 21:54:25 +0900 Subject: [PATCH 45/55] =?UTF-8?q?test=20:=20(=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/product/ProductServiceTest.java | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) 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 d4c12f3ae..6259f6e0e 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 @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.given; import com.loopers.support.error.CoreException; +import java.util.List; import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -58,15 +59,39 @@ void fail_when_product_not_found() { } @Nested - @DisplayName("상품 목록 조회") + @DisplayName("상품 조회") class GetProducts { @Test @DisplayName("검색 조건이 null이면, 예외를 던진다") void fail_when_condition_is_null() { - assertThatThrownBy(() -> productService.getProducts(null)) + assertThatThrownBy(() -> productService.findProducts(null)) .isInstanceOf(CoreException.class) .hasMessage("검색 조건은 필수입니다"); } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 ID 목록이 null이거나 비어있으면, 예외를 던진다") + void fail_when_ids_is_null_or_empty(List ids) { + assertThatThrownBy(() -> productService.getByIds(ids)) + .isInstanceOf(CoreException.class) + .hasMessage("상품 ID 목록은 필수입니다"); + } + + @Test + @DisplayName("존재하지 않는 상품이 포함되어 있으면, 예외를 던진다") + void fail_when_some_products_not_found() { + List ids = List.of(1L, 2L, 3L); + List foundProducts = List.of( + Product.create(1L, "상품1", 1L, 1000, 10, 0) + ); + + given(productRepository.findByIds(ids)).willReturn(foundProducts); + + assertThatThrownBy(() -> productService.getByIds(ids)) + .isInstanceOf(CoreException.class) + .hasMessage("존재하지 않는 상품이 포함되어 있습니다"); + } } } From 4af8071ffce6e91cd2ee7a840a65e0b4541e2523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 22:45:31 +0900 Subject: [PATCH 46/55] =?UTF-8?q?refactor=20:=20(=EC=A3=BC=EB=AC=B8=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8)=20=EC=A3=BC=EB=AC=B8=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=8A=A4=20=EB=A1=9C=EC=A7=81=EC=9D=84=20fac?= =?UTF-8?q?ade=20->=20service=20=EA=B3=84=EC=B8=B5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 29 ++-------------- .../com/loopers/domain/order/OrderItem.java | 4 +++ .../loopers/domain/order/OrderService.java | 34 ++++++++++++++++--- 3 files changed, 36 insertions(+), 31 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 633c8fbbc..95a2007f6 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,15 +1,12 @@ package com.loopers.application.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.UserService; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,35 +17,15 @@ public class OrderFacade { private final ProductService productService; - private final UserService userService; private final OrderService orderService; public void order(Long userId, Map productQuantities) { - Long validatedUserId = userService.findById(userId).getId(); - List productIds = new ArrayList<>(productQuantities.keySet()); List products = productService.getByIds(productIds); - Map productWithQuantities = products.stream() - .collect(Collectors.toMap( - product -> product, - product -> productQuantities.get(product.getId()), - (a, b) -> a, - LinkedHashMap::new - )); - - productWithQuantities.forEach((product, quantity) -> - productService.decreaseStock(product.getId(), quantity)); - - int totalPrice = productWithQuantities.entrySet().stream() - .mapToInt(e -> e.getKey().getPrice() * e.getValue()) - .sum(); - - var order = orderService.createOrder(validatedUserId, totalPrice); + products.forEach(product -> + productService.decreaseStock(product.getId(), productQuantities.get(product.getId()))); - List orderItems = productWithQuantities.entrySet().stream() - .map(e -> OrderItem.create(null, order.getId(), e.getKey().getId(), e.getValue(), e.getKey().getPrice())) - .toList(); - orderService.createOrderItems(order.getId(), orderItems); + orderService.createOrderWithItems(userId, products, productQuantities); } } 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 397b01a7b..b18de69e4 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 @@ -40,4 +40,8 @@ private static void validatePrice(Integer price) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 null이거나 음수가 될 수 없습니다"); } } + + public static int calculateSubtotal(int unitPrice, int quantity) { + return unitPrice * 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 b0fca4b05..7603d6bbe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,7 +1,9 @@ package com.loopers.domain.order; +import com.loopers.domain.product.Product; import java.time.ZonedDateTime; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,7 +15,7 @@ public class OrderService { private final OrderItemRepository orderItemRepository; private final OrderStatusHistoryRepository orderStatusHistoryRepository; - public Order createOrder(Long refUserId, Integer totalPrice) { + private Order createOrder(Long refUserId, Integer totalPrice) { Order order = Order.create(null, refUserId, OrderStatus.ORDERED, totalPrice, ZonedDateTime.now()); Order savedOrder = orderRepository.save(order); @@ -22,15 +24,37 @@ public Order createOrder(Long refUserId, Integer totalPrice) { return savedOrder; } - public List createOrderItems(Long orderId, List orderItems) { - List items = orderItems.stream() - .map(item -> OrderItem.create(null, orderId, item.refProductId(), item.quantity(), item.price())) + private void createOrderItems(Long orderId, List products, Map productQuantities) { + List orderItems = products.stream() + .map(product -> OrderItem.create( + null, + orderId, + product.getId(), + productQuantities.get(product.getId()), + product.getPrice() + )) .toList(); - return orderItemRepository.saveAll(items); + orderItemRepository.saveAll(orderItems); } private void recordHistory(Long orderId, OrderStatus status) { OrderStatusHistory history = OrderStatusHistory.create(null, orderId, status, ZonedDateTime.now()); orderStatusHistoryRepository.save(history); } + + public Order createOrderWithItems(Long userId, List products, Map productQuantities) { + int totalPrice = calculateTotalPrice(products, productQuantities); + Order order = createOrder(userId, totalPrice); + createOrderItems(order.getId(), products, productQuantities); + return order; + } + + private int calculateTotalPrice(List products, Map productQuantities) { + return products.stream() + .mapToInt(product -> OrderItem.calculateSubtotal( + product.getPrice(), + productQuantities.get(product.getId()) + )) + .sum(); + } } From a9b71dd04621a1d72f40a2d354dd2e129d4c0342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 23:19:05 +0900 Subject: [PATCH 47/55] =?UTF-8?q?refactor=20:=20orderItem,=20orderStatusHi?= =?UTF-8?q?story=EC=9D=98=20service=EB=93=A4=EC=9D=84=20=EB=94=B0=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 14 ++++- .../domain/order/OrderItemService.java | 46 ++++++++++++++++ .../loopers/domain/order/OrderService.java | 55 +++---------------- .../order/OrderStatusHistoryService.java | 22 ++++++++ 4 files changed, 89 insertions(+), 48 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.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 95a2007f6..faab2d10e 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,9 +1,11 @@ package com.loopers.application.order; +import com.loopers.domain.order.OrderItemService; import com.loopers.domain.order.OrderService; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.OrderStatusHistoryService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; -import com.loopers.domain.user.UserService; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -18,6 +20,8 @@ public class OrderFacade { private final ProductService productService; private final OrderService orderService; + private final OrderItemService orderItemService; + private final OrderStatusHistoryService orderStatusHistoryService; public void order(Long userId, Map productQuantities) { List productIds = new ArrayList<>(productQuantities.keySet()); @@ -26,6 +30,12 @@ public void order(Long userId, Map productQuantities) { products.forEach(product -> productService.decreaseStock(product.getId(), productQuantities.get(product.getId()))); - orderService.createOrderWithItems(userId, products, productQuantities); + int totalPrice = orderItemService.calculateTotalPrice(products, productQuantities); + + var order = orderService.createOrder(userId, totalPrice); + + orderItemService.createOrderItems(order.getId(), products, productQuantities); + + orderStatusHistoryService.recordHistory(order.getId(), OrderStatus.ORDERED); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java new file mode 100644 index 000000000..c92b3dbf9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java @@ -0,0 +1,46 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderItemService { + + private final OrderItemRepository orderItemRepository; + + public void createOrderItems(Long orderId, List products, Map productQuantities) { + if (products == null || products.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문할 상품이 없습니다"); + } + if (productQuantities == null || productQuantities.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량 정보가 없습니다"); + } + + List orderItems = products.stream() + .map(product -> OrderItem.create( + null, + orderId, + product.getId(), + productQuantities.get(product.getId()), + product.getPrice() + )) + .toList(); + + orderItemRepository.saveAll(orderItems); + } + + public int calculateTotalPrice(List products, Map productQuantities) { + return products.stream() + .mapToInt(product -> OrderItem.calculateSubtotal( + product.getPrice(), + productQuantities.get(product.getId()) + )) + .sum(); + } +} 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 7603d6bbe..b89b72540 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,9 +1,6 @@ package com.loopers.domain.order; -import com.loopers.domain.product.Product; import java.time.ZonedDateTime; -import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -12,49 +9,15 @@ public class OrderService { private final OrderRepository orderRepository; - private final OrderItemRepository orderItemRepository; - private final OrderStatusHistoryRepository orderStatusHistoryRepository; - private Order createOrder(Long refUserId, Integer totalPrice) { - Order order = Order.create(null, refUserId, OrderStatus.ORDERED, totalPrice, ZonedDateTime.now()); - Order savedOrder = orderRepository.save(order); - - recordHistory(savedOrder.getId(), OrderStatus.ORDERED); - - return savedOrder; - } - - private void createOrderItems(Long orderId, List products, Map productQuantities) { - List orderItems = products.stream() - .map(product -> OrderItem.create( - null, - orderId, - product.getId(), - productQuantities.get(product.getId()), - product.getPrice() - )) - .toList(); - orderItemRepository.saveAll(orderItems); - } - - private void recordHistory(Long orderId, OrderStatus status) { - OrderStatusHistory history = OrderStatusHistory.create(null, orderId, status, ZonedDateTime.now()); - orderStatusHistoryRepository.save(history); - } - - public Order createOrderWithItems(Long userId, List products, Map productQuantities) { - int totalPrice = calculateTotalPrice(products, productQuantities); - Order order = createOrder(userId, totalPrice); - createOrderItems(order.getId(), products, productQuantities); - return order; - } - - private int calculateTotalPrice(List products, Map productQuantities) { - return products.stream() - .mapToInt(product -> OrderItem.calculateSubtotal( - product.getPrice(), - productQuantities.get(product.getId()) - )) - .sum(); + public Order createOrder(Long refUserId, Integer totalPrice) { + Order order = Order.create( + null, + refUserId, + OrderStatus.ORDERED, + totalPrice, + ZonedDateTime.now() + ); + return orderRepository.save(order); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.java new file mode 100644 index 000000000..2794838cb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistoryService.java @@ -0,0 +1,22 @@ +package com.loopers.domain.order; + +import java.time.ZonedDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OrderStatusHistoryService { + + private final OrderStatusHistoryRepository orderStatusHistoryRepository; + + public void recordHistory(Long orderId, OrderStatus status) { + OrderStatusHistory history = OrderStatusHistory.create( + null, + orderId, + status, + ZonedDateTime.now() + ); + orderStatusHistoryRepository.save(history); + } +} From e725232b154fc9dd281c6cc5cdb8069909180bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 23:25:54 +0900 Subject: [PATCH 48/55] =?UTF-8?q?test=20:=20orderItem=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/OrderItemServiceTest.java | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java new file mode 100644 index 000000000..9403c0eb9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java @@ -0,0 +1,77 @@ +package com.loopers.domain.order; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import java.util.List; +import java.util.Map; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class OrderItemServiceTest { + + @InjectMocks + private OrderItemService orderItemService; + + @Nested + @DisplayName("주문 아이템 생성") + class CreateOrderItems { + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("상품 목록이 null이거나 비어있으면, 예외를 던진다") + void fail_when_products_is_null_or_empty(List products) { + Long orderId = 1L; + Map productQuantities = Map.of(1L, 2); + + assertThatThrownBy(() -> orderItemService.createOrderItems(orderId, products, productQuantities)) + .isInstanceOf(CoreException.class) + .hasMessage("주문할 상품이 없습니다"); + } + + @ParameterizedTest + @NullAndEmptySource + @DisplayName("주문 수량 정보가 null이거나 비어있으면, 예외를 던진다") + void fail_when_quantities_is_null_or_empty(Map productQuantities) { + Long orderId = 1L; + List products = List.of( + Product.create(1L, "상품1", 1L, 1000, 10, 0) + ); + + assertThatThrownBy(() -> orderItemService.createOrderItems(orderId, products, productQuantities)) + .isInstanceOf(CoreException.class) + .hasMessage("주문 수량 정보가 없습니다"); + } + } + + @Nested + @DisplayName("총 가격 계산") + class CalculateTotalPrice { + + @Test + @DisplayName("상품 목록과 수량으로 총 가격을 계산한다") + void success_calculate_total_price() { + List products = List.of( + Product.create(1L, "상품1", 1L, 1000, 10, 0), + Product.create(2L, "상품2", 1L, 500, 10, 0) + ); + Map productQuantities = Map.of( + 1L, 2, + 2L, 3 + ); + + int totalPrice = orderItemService.calculateTotalPrice(products, productQuantities); + + assertThat(totalPrice).isEqualTo(3500); + } + } +} From 099ecd9b789bb10a24aa17c2fb31ae2f12e8a350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Thu, 26 Feb 2026 23:43:24 +0900 Subject: [PATCH 49/55] =?UTF-8?q?test=20:=20(=EC=A2=8B=EC=95=84=EC=9A=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8)=20like=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../product/ProductRepositoryImpl.java | 3 +- .../loopers/domain/like/LikeServiceTest.java | 76 ++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) 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 79a2c983a..88a17c259 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 @@ -71,8 +71,7 @@ private OrderSpecifier getOrderSpecifier(ProductSortType sortType) { return switch (sortType) { case LATEST -> productEntity.createdAt.desc(); case PRICE_ASC -> productEntity.price.asc(); -// case LIKES_DESC -> productEntity.createdAt.desc(); - case LIKES_DESC -> null; + case LIKES_DESC -> productEntity.likeCount.desc(); }; } } 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 233e66ee4..7e7a282fe 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 @@ -1,10 +1,15 @@ package com.loopers.domain.like; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import com.loopers.support.error.CoreException; +import java.util.Optional; 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.InjectMocks; @@ -20,16 +25,69 @@ class LikeServiceTest { @Mock private LikeRepository likeRepository; - @DisplayName("이미 좋아요를 누른 상품이면, 예외를 던진다") - @Test - void fail_like_when_already_liked() { - Long productId = 1L; - Long userId = 2L; + @Nested + @DisplayName("좋아요 등록") + class LikeTest { - given(likeRepository.existByUniqueId(productId, userId)).willReturn(true); + @Test + @DisplayName("좋아요를 등록한다") + void success_like() { + Long productId = 1L; + Long userId = 2L; + Like like = Like.create(1L, productId, userId); - assertThatThrownBy(() -> likeService.like(productId, userId)) - .isInstanceOf(CoreException.class) - .hasMessage("이미 좋아요를 누른 상품입니다"); + given(likeRepository.existByUniqueId(productId, userId)).willReturn(false); + given(likeRepository.save(any(Like.class))).willReturn(like); + + Like result = likeService.like(productId, userId); + + assertThat(result.getRefProductId()).isEqualTo(productId); + assertThat(result.getRefUserId()).isEqualTo(userId); + } + + @Test + @DisplayName("이미 좋아요를 누른 상품이면, 예외를 던진다") + void fail_when_already_liked() { + Long productId = 1L; + Long userId = 2L; + + given(likeRepository.existByUniqueId(productId, userId)).willReturn(true); + + assertThatThrownBy(() -> likeService.like(productId, userId)) + .isInstanceOf(CoreException.class) + .hasMessage("이미 좋아요를 누른 상품입니다"); + } + } + + @Nested + @DisplayName("좋아요 취소") + class UnlikeTest { + + @Test + @DisplayName("좋아요를 취소한다") + void success_unlike() { + Long productId = 1L; + Long userId = 2L; + Like like = Like.create(1L, productId, userId); + + given(likeRepository.findByUniqueId(productId, userId)).willReturn(Optional.of(like)); + + likeService.unlike(productId, userId); + + then(likeRepository).should().delete(like); + } + + @Test + @DisplayName("좋아요가 존재하지 않으면, 예외를 던진다") + void fail_when_not_liked() { + Long productId = 1L; + Long userId = 2L; + + given(likeRepository.findByUniqueId(productId, userId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> likeService.unlike(productId, userId)) + .isInstanceOf(CoreException.class) + .hasMessage("좋아요 객체를 찾을 수 없습니다"); + } } } From 3f94e66999449e53d499d522ee2b7ec3e6344f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 27 Feb 2026 00:18:27 +0900 Subject: [PATCH 50/55] =?UTF-8?q?feat=20:=20interface=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EC=9E=91=EC=84=B1=20&=20application=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20command=20dto=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderCommand.java | 9 ++ .../application/order/OrderFacade.java | 15 +-- .../loopers/application/order/OrderInfo.java | 23 ++++ .../product/CreateProductCommand.java | 14 +++ .../application/product/ProductFacade.java | 44 ++++++-- .../product/ProductSearchCommand.java | 14 +++ .../interfaces/api/like/LikeV1Controller.java | 28 +++++ .../api/order/OrderV1Controller.java | 35 ++++++ .../interfaces/api/order/OrderV1Dto.java | 60 +++++++++++ .../api/product/ProductV1Controller.java | 65 +++++++++++ .../interfaces/api/product/ProductV1Dto.java | 102 ++++++++++++++++++ 11 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.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/product/CreateProductCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.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/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 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java new file mode 100644 index 000000000..5fd34b714 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java @@ -0,0 +1,9 @@ +package com.loopers.application.order; + +import java.util.Map; + +public record OrderCommand( + Long userId, + Map productQuantities +) { +} 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 faab2d10e..0226d1483 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 @@ -8,7 +8,6 @@ import com.loopers.domain.product.ProductService; import java.util.ArrayList; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,19 +22,21 @@ public class OrderFacade { private final OrderItemService orderItemService; private final OrderStatusHistoryService orderStatusHistoryService; - public void order(Long userId, Map productQuantities) { - List productIds = new ArrayList<>(productQuantities.keySet()); + public OrderInfo order(OrderCommand command) { + List productIds = new ArrayList<>(command.productQuantities().keySet()); List products = productService.getByIds(productIds); products.forEach(product -> - productService.decreaseStock(product.getId(), productQuantities.get(product.getId()))); + productService.decreaseStock(product.getId(), command.productQuantities().get(product.getId()))); - int totalPrice = orderItemService.calculateTotalPrice(products, productQuantities); + int totalPrice = orderItemService.calculateTotalPrice(products, command.productQuantities()); - var order = orderService.createOrder(userId, totalPrice); + var order = orderService.createOrder(command.userId(), totalPrice); - orderItemService.createOrderItems(order.getId(), products, productQuantities); + orderItemService.createOrderItems(order.getId(), products, command.productQuantities()); orderStatusHistoryService.recordHistory(order.getId(), OrderStatus.ORDERED); + + return OrderInfo.from(order); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..2a914b574 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderStatus; +import java.time.ZonedDateTime; + +public record OrderInfo( + Long id, + Long userId, + OrderStatus status, + Integer totalPrice, + ZonedDateTime orderDt +) { + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getRefUserId(), + order.getStatus(), + order.getTotalPrice(), + order.getOrderDt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductCommand.java new file mode 100644 index 000000000..d56b60288 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.product; + +import java.util.Map; + +public record CreateProductCommand( + Map products +) { + public record ProductItem( + String name, + Integer price, + Integer stock + ) { + } +} 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 64738e4d6..ec392a2fb 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 @@ -6,6 +6,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductService; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; @@ -20,9 +21,25 @@ public class ProductFacade { private final BrandService brandService; private final ProductService productService; - public List createProducts(Map command) { - command.keySet().forEach(brandService::getById); - return productService.createProducts(command); + public List createProducts(CreateProductCommand command) { + command.products().keySet().forEach(brandService::getById); + + Map domainRequest = new HashMap<>(); + command.products().forEach((brandId, item) -> { + domainRequest.put(brandId, new CreateProductRequest( + item.name(), + item.price(), + item.stock() + )); + }); + + List products = productService.createProducts(domainRequest); + return products.stream() + .map(product -> { + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + }) + .toList(); } @Transactional(readOnly = true) @@ -33,10 +50,23 @@ public ProductInfo getProduct(Long productId) { } @Transactional(readOnly = true) - public List getProducts(ProductSearchCondition condition) { - if (condition.hasBrandId()) { - brandService.getById(condition.brandId()); + public List getProducts(ProductSearchCommand command) { + if (command.hasBrandId()) { + brandService.getById(command.brandId()); } - return productService.findProducts(condition); + + ProductSearchCondition condition = new ProductSearchCondition( + command.brandId(), + command.sortType(), + command.page(), + command.size() + ); + + return productService.findProducts(condition).stream() + .map(product -> { + Brand brand = brandService.getById(product.getRefBrandId()); + return ProductInfo.of(product, brand); + }) + .toList(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java new file mode 100644 index 000000000..4687b2f81 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductSearchCommand.java @@ -0,0 +1,14 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductSortType; + +public record ProductSearchCommand( + Long brandId, + ProductSortType sortType, + int page, + int size +) { + public boolean hasBrandId() { + return brandId != null; + } +} 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..92d30d140 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.user.AuthUserPrincipal; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class LikeV1Controller { + + private final LikeFacade likeFacade; + + @PostMapping("/{productId}/like") + public ApiResponse toggleLike( + @PathVariable Long productId, + @AuthUser AuthUserPrincipal user + ) { + likeFacade.toggleLike(productId, user.getId()); + return ApiResponse.success(); + } +} 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..77edb5bb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderCommand; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.application.user.AuthUserPrincipal; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.AuthUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller { + + private final OrderFacade orderFacade; + + @PostMapping + public ApiResponse createOrder( + @AuthUser AuthUserPrincipal user, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + OrderCommand command = new OrderCommand( + user.getId(), + request.toProductQuantities() + ); + OrderInfo orderInfo = orderFacade.order(command); + return ApiResponse.success(OrderV1Dto.CreateOrderResponse.from(orderInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..72537824f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderStatus; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class OrderV1Dto { + + public record CreateOrderRequest( + + @NotEmpty(message = "주문 상품은 필수입니다") + @Valid + List items + ) { + public Map toProductQuantities() { + return items.stream() + .collect(Collectors.toMap( + OrderItemRequest::productId, + OrderItemRequest::quantity + )); + } + } + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다") + Long productId, + + @NotNull(message = "수량은 필수입니다") + @Min(value = 1, message = "수량은 1개 이상이어야 합니다") + Integer quantity + ) { + + } + + public record CreateOrderResponse( + Long id, + Long userId, + OrderStatus status, + Integer totalPrice, + ZonedDateTime orderDt + ) { + + public static CreateOrderResponse from(OrderInfo orderInfo) { + return new CreateOrderResponse( + orderInfo.id(), + orderInfo.userId(), + orderInfo.status(), + orderInfo.totalPrice(), + orderInfo.orderDt() + ); + } + } +} 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..23016637e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,65 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.CreateProductCommand; +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductSearchCommand; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller { + + private final ProductFacade productFacade; + + @PostMapping + public ApiResponse createProducts( + @Valid @RequestBody ProductV1Dto.CreateProductRequest request + ) { + CreateProductCommand command = new CreateProductCommand( + request.toProductItems() + ); + List productInfos = productFacade.createProducts(command); + return ApiResponse.success(ProductV1Dto.CreateProductResponse.from(productInfos)); + } + + @GetMapping("/{productId}") + public ApiResponse getProduct( + @PathVariable Long productId + ) { + ProductInfo info = productFacade.getProduct(productId); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(info)); + } + + @GetMapping + public ApiResponse> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "LATEST") ProductSortType sortType, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + ProductSearchCommand command = new ProductSearchCommand( + brandId, + sortType, + page, + size + ); + List productInfos = productFacade.getProducts(command); + List response = productInfos.stream() + .map(ProductV1Dto.ProductResponse::from) + .toList(); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..94ac0f18f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,102 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class ProductV1Dto { + + public record CreateProductRequest( + @NotEmpty(message = "상품 목록은 필수입니다") + @Valid + List products + ) { + public Map toProductItems() { + return products.stream() + .collect(Collectors.toMap( + ProductItemRequest::brandId, + item -> new com.loopers.application.product.CreateProductCommand.ProductItem( + item.name(), + item.price(), + item.stock() + ) + )); + } + } + + public record ProductItemRequest( + @NotNull(message = "브랜드 ID는 필수입니다") + Long brandId, + + @NotBlank(message = "상품명은 필수입니다") + String name, + + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + Integer price, + + @NotNull(message = "재고는 필수입니다") + @Min(value = 0, message = "재고는 0개 이상이어야 합니다") + Integer stock + ) { + } + + public record CreateProductResponse( + List products + ) { + public static CreateProductResponse from(List productInfos) { + List products = productInfos.stream() + .map(ProductResponse::from) + .toList(); + return new CreateProductResponse(products); + } + } + + public record ProductResponse( + Long id, + String name, + Integer price, + Integer stock, + Integer likeCount + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.price(), + info.stock(), + info.likeCount() + ); + } + } + + public record ProductDetailResponse( + Long id, + String name, + Integer price, + Integer stock, + Integer likeCount, + Long brandId, + String brandName, + String brandDescription + ) { + public static ProductDetailResponse from(ProductInfo info) { + return new ProductDetailResponse( + info.id(), + info.name(), + info.price(), + info.stock(), + info.likeCount(), + info.brandId(), + info.brandName(), + info.brandDescription() + ); + } + } +} From 073de4dfeff627a3da9692cbd9899c61bb865f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 27 Feb 2026 00:29:22 +0900 Subject: [PATCH 51/55] =?UTF-8?q?refactor=20:=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EC=98=88=EC=99=B8=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/brand/Brand.java | 4 +- .../loopers/domain/brand/BrandService.java | 3 +- .../loopers/domain/example/ExampleModel.java | 7 +- .../domain/example/ExampleService.java | 3 +- .../java/com/loopers/domain/like/Like.java | 5 +- .../com/loopers/domain/like/LikeService.java | 5 +- .../java/com/loopers/domain/order/Order.java | 9 +- .../com/loopers/domain/order/OrderItem.java | 9 +- .../domain/order/OrderItemService.java | 5 +- .../domain/order/OrderStatusHistory.java | 7 +- .../com/loopers/domain/product/Product.java | 17 +-- .../domain/product/ProductService.java | 11 +- .../com/loopers/domain/user/UserModel.java | 11 +- .../com/loopers/domain/user/UserService.java | 17 +-- .../brand/BrandRepositoryImpl.java | 3 +- .../product/ProductRepositoryImpl.java | 3 +- .../api/AuthUserArgumentResolver.java | 3 +- .../loopers/support/error/ErrorMessage.java | 115 ++++++++++++++++++ .../loopers/domain/user/UserServiceTest.java | 15 +-- 19 files changed, 192 insertions(+), 60 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index ed4cebfe4..e33c33db0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -1,8 +1,8 @@ package com.loopers.domain.brand; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; -import java.time.ZonedDateTime; /** * 브랜드 도메인 객체 @@ -27,7 +27,7 @@ public static Brand create(Long id, String name, String description) { private static void validateName(String name) { if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수 입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Brand.BRAND_NAME_REQUIRED); } } 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 ec7e4e484..7e2c37bc8 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 @@ -1,6 +1,7 @@ package com.loopers.domain.brand; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,6 +25,6 @@ public Brand update(Long id, String name, String description) { public Brand getById(Long id) { return brandRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "브랜드를 찾을 수 없습니다")); + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Brand.BRAND_NOT_FOUND)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java index c588c4a8a..3401028b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleModel.java @@ -2,6 +2,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -17,10 +18,10 @@ protected ExampleModel() {} public ExampleModel(String name, String description) { if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Example.NAME_REQUIRED); } if (description == null || description.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Example.DESCRIPTION_REQUIRED); } this.name = name; @@ -37,7 +38,7 @@ public String getDescription() { public void update(String newDescription) { if (newDescription == null || newDescription.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "설명은 비어있을 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Example.DESCRIPTION_REQUIRED); } this.description = newDescription; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java index c0e8431e8..9a634ac25 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/example/ExampleService.java @@ -1,6 +1,7 @@ package com.loopers.domain.example; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -15,6 +16,6 @@ public class ExampleService { @Transactional(readOnly = true) public ExampleModel getExample(Long id) { return exampleRepository.find(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 예시를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.Example.EXAMPLE_NOT_FOUND)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java index f1c3ea25b..cf7acc286 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -1,6 +1,7 @@ package com.loopers.domain.like; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; /** @@ -26,11 +27,11 @@ public static Like create(Long id, Long refProductId, Long refUserId) { private static void validateRefId(Long refProductId, Long refUserId) { if (refProductId == null || refProductId < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품FK는 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.PRODUCT_ID_INVALID); } if (refUserId == null || refUserId < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "유저FK는 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.USER_ID_INVALID); } } 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 16b5134ff..58d72adc8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -1,6 +1,7 @@ package com.loopers.domain.like; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,7 +14,7 @@ public class LikeService { public Like like(Long productId, Long userId) { if (likeRepository.existByUniqueId(productId, userId)) { - throw new CoreException(ErrorType.BAD_REQUEST, "이미 좋아요를 누른 상품입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.ALREADY_LIKED); } Like like = Like.create(null, productId, userId); @@ -33,6 +34,6 @@ public boolean isLiked(Long productId, Long userId) { public Like findByUniqueId(Long productId, Long userId) { return likeRepository.findByUniqueId(productId, userId) - .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "좋아요 객체를 찾을 수 없습니다")); + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Like.LIKE_NOT_FOUND)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 62e7a850d..1c9a4efba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import java.time.ZonedDateTime; @@ -34,25 +35,25 @@ public static Order create(Long id, Long refUserId, OrderStatus status, Integer private static void validateRefUserId(Long refUserId) { if (refUserId == null || refUserId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "유저FK는 null이거나 0이하가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.USER_ID_INVALID); } } private static void validateTotalPrice(Integer totalPrice) { if (totalPrice == null || totalPrice < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "총 주문 금액은 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.TOTAL_ORDER_AMOUNT_INVALID); } } private static void validateOrderDt(ZonedDateTime orderDt) { if (orderDt == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 일시는 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_DT_REQUIRED); } } public void cancel() { if (this.status != OrderStatus.ORDERED) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 완료 상태에서만 취소할 수 있습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.CANCEL_ONLY_WHEN_COMPLETED); } this.status = OrderStatus.CANCELLED; } 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 b18de69e4..28820bf5b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; /** @@ -19,25 +20,25 @@ public static OrderItem create(Long id, Long refOrderId, Long refProductId, Inte private static void validateRefOrderId(Long refOrderId) { if (refOrderId == null || refOrderId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문FK는 null이거나 0이하가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ID_INVALID); } } private static void validateRefProductId(Long refProductId) { if (refProductId == null || refProductId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품FK는 null이거나 0이하가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.PRODUCT_ID_INVALID); } } private static void validateQuantity(Integer quantity) { if (quantity == null || quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "수량은 양수여야 합니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.QUANTITY_MUST_BE_POSITIVE); } } private static void validatePrice(Integer price) { if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 금액은 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_AMOUNT_INVALID); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java index c92b3dbf9..21ffaadbd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java @@ -2,6 +2,7 @@ import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; @@ -16,10 +17,10 @@ public class OrderItemService { public void createOrderItems(Long orderId, List products, Map productQuantities) { if (products == null || products.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문할 상품이 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ITEMS_EMPTY); } if (productQuantities == null || productQuantities.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량 정보가 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_QUANTITIES_EMPTY); } List orderItems = products.stream() diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java index b4e956cb8..fe1084f42 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import java.time.ZonedDateTime; @@ -19,19 +20,19 @@ public static OrderStatusHistory create(Long id, Long refOrderId, OrderStatus st private static void validateRefOrderId(Long refOrderId) { if (refOrderId == null || refOrderId <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문FK는 null이거나 0이하가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ID_INVALID); } } private static void validateStatus(OrderStatus status) { if (status == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 상태는 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_REQUIRED); } } private static void validateChangedAt(ZonedDateTime changedAt) { if (changedAt == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 상태 변경 일시는 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_CHANGE_DT_REQUIRED); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 6ef200699..1b041db9a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; /** @@ -37,31 +38,31 @@ public static Product create(Long id, String name, Long refBrandId, Integer pric private static void validateName(String name) { if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수 입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_NAME_REQUIRED); } } private static void validateBrandId(Long refBrand) { if (refBrand == null || refBrand <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드FK는 null이거나 0이하가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.BRAND_ID_INVALID); } } private static void validatePrice(Integer price) { if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRICE_INVALID); } } private static void validateStock(Integer stock) { if (stock == null || stock < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.STOCK_INVALID); } } private static void validateLike(Integer likeCount) { if (likeCount == null || likeCount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 null이거나 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.LIKE_COUNT_INVALID); } } @@ -72,7 +73,7 @@ public boolean hasEnoughStock(Integer requiredQuantity) { public void decreaseStock(Integer quantity) { validateQuantity(quantity); if (!hasEnoughStock(quantity)) { - throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.INSUFFICIENT_STOCK); } this.stock -= quantity; } @@ -83,14 +84,14 @@ public void increaseLikeCount() { public void decreaseLikeCount() { if(this.likeCount <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 갯수는 음수가 될 수 없습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.LIKE_COUNT_NEGATIVE); } this.likeCount -= 1; } private void validateQuantity(Integer quantity) { if (quantity == null || quantity <= 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "수량은 양수여야 합니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.QUANTITY_MUST_BE_POSITIVE); } } 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 5dd2f3875..22fece86e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import java.util.ArrayList; import java.util.List; @@ -16,7 +17,7 @@ public class ProductService { public List createProducts(Map createProductsCommand) { if (createProductsCommand == null || createProductsCommand.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 생성 요청은 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.CREATE_PRODUCT_REQUEST_REQUIRED); } List createdProducts = new ArrayList<>(); @@ -57,23 +58,23 @@ public void decreaseStock(Long productId, Integer decreaseStock) { public Product getById(Long id) { return productRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, "상품을 찾을 수 없습니다")); + .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_NOT_FOUND)); } public List getByIds(List ids) { if (ids == null || ids.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID 목록은 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_ID_LIST_REQUIRED); } List products = productRepository.findByIds(ids); if (products.size() != ids.size()) { - throw new CoreException(ErrorType.BAD_REQUEST, "존재하지 않는 상품이 포함되어 있습니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRODUCT_ID_LIST_CONTAINS_INVALID); } return products; } public List findProducts(ProductSearchCondition condition) { if (condition == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "검색 조건은 필수입니다"); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.SEARCH_CONDITION_REQUIRED); } return productRepository.findAll(condition); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index b19ca04bf..016410276 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -2,6 +2,7 @@ import com.loopers.domain.BaseEntity; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -54,19 +55,19 @@ private UserModel(String loginId, String password, LocalDate birthDate, String n @Override protected void guard() { if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.LOGIN_ID_REQUIRED); } if (password == null || password.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호는 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.PASSWORD_REQUIRED); } if (birthDate == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.BIRTH_DATE_REQUIRED); } if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.NAME_REQUIRED); } if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 필수입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.EMAIL_REQUIRED); } } 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 7f65e7722..44cf6fafc 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,7 @@ package com.loopers.domain.user; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.transaction.Transactional; import java.time.LocalDate; @@ -18,10 +19,10 @@ public class UserService { @Transactional public UserModel createUser(String loginId, String rawPassword, LocalDate birthDate, String name, String email) { if (userRepository.existsByLoginId(loginId)) { - throw new CoreException(ErrorType.BAD_REQUEST, "이미 사용 중인 아이디입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.LOGIN_ID_ALREADY_EXISTS); } if (userRepository.existsByEmail(email)) { - throw new CoreException(ErrorType.BAD_REQUEST, "이미 가입된 이메일입니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.EMAIL_ALREADY_EXISTS); } validatePasswordNotContainsBirthDate(rawPassword, birthDate); @@ -32,9 +33,9 @@ public UserModel createUser(String loginId, String rawPassword, LocalDate birthD public UserModel authenticate(String loginId, String rawPassword) { UserModel user = userRepository.findByLoginId(loginId) - .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.UNAUTHORIZED, ErrorMessage.User.INVALID_LOGIN_INFO)); if (!passwordEncoder.matches(rawPassword, user.getPassword())) { - throw new CoreException(ErrorType.UNAUTHORIZED, "로그인 정보가 올바르지 않습니다."); + throw new CoreException(ErrorType.UNAUTHORIZED, ErrorMessage.User.INVALID_LOGIN_INFO); } return user; } @@ -45,7 +46,7 @@ public Boolean existsByEmail(String email) { public UserModel findById(Long id) { return userRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.User.USER_NOT_FOUND)); } @Transactional @@ -53,11 +54,11 @@ public void changePassword(Long userId, String currentPassword, String newPasswo UserModel user = findById(userId); if (!passwordEncoder.matches(currentPassword, user.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "기존 비밀번호가 일치하지 않습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.CURRENT_PASSWORD_MISMATCH); } if (passwordEncoder.matches(newPassword, user.getPassword())) { - throw new CoreException(ErrorType.BAD_REQUEST, "새 비밀번호는 기존 비밀번호와 달라야 합니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.NEW_PASSWORD_SAME_AS_CURRENT); } validatePasswordNotContainsBirthDate(newPassword, user.getBirthDate()); @@ -69,7 +70,7 @@ public void changePassword(Long userId, String currentPassword, String newPasswo private void validatePasswordNotContainsBirthDate(String password, LocalDate birthDate) { String birthStr = birthDate.toString().replace("-", ""); if (password.contains(birthStr)) { - throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호에 생년월일을 포함할 수 없습니다."); + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.User.PASSWORD_CONTAINS_BIRTH_DATE); } } } 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 index bf00f130e..965782be4 100644 --- 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 @@ -3,6 +3,7 @@ import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -24,7 +25,7 @@ public Brand create(Brand brand) { @Override public Brand update(Brand brand) { BrandEntity brandEntity = brandJpaRepository.findById(brand.getId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.Brand.BRAND_NOT_FOUND)); brandEntity.update(brand); return BrandEntity.toDomain(brandJpaRepository.save(brandEntity)); 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 88a17c259..fb140b0ee 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 com.loopers.domain.product.ProductSearchCondition; import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -45,7 +46,7 @@ public List findByIds(List ids) { @Override public Product update(Product product) { ProductEntity productEntity = productJpaRepository.findById(product.getId()) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, ErrorMessage.Product.PRODUCT_NOT_FOUND)); productEntity.update(product); return ProductEntity.toDomain(productJpaRepository.save(productEntity)); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java index 61b17d315..3a0ef9704 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/AuthUserArgumentResolver.java @@ -4,6 +4,7 @@ import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -42,7 +43,7 @@ public Object resolveArgument( String loginPw = request.getHeader(LoopersHeaders.X_LOOPERS_LOGIN_PW); if (loginId == null || loginId.isBlank() || loginPw == null || loginPw.isBlank()) { - throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); + throw new CoreException(ErrorType.UNAUTHORIZED, ErrorMessage.Auth.AUTH_HEADER_MISSING); } UserModel user = userService.authenticate(loginId, loginPw); diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java new file mode 100644 index 000000000..a449a225e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java @@ -0,0 +1,115 @@ +package com.loopers.support.error; + +/** + * 예외 메시지를 중앙에서 관리하는 클래스 + * 프로덕션 코드와 테스트 코드에서 공통으로 사용 + */ +public final class ErrorMessage { + + private ErrorMessage() { + throw new AssertionError("Utility class should not be instantiated"); + } + + /** + * User 도메인 관련 에러 메시지 + */ + public static final class User { + private User() {} + + public static final String LOGIN_ID_ALREADY_EXISTS = "이미 사용 중인 아이디입니다."; + public static final String EMAIL_ALREADY_EXISTS = "이미 가입된 이메일입니다."; + public static final String INVALID_LOGIN_INFO = "로그인 정보가 올바르지 않습니다."; + public static final String USER_NOT_FOUND = "사용자를 찾을 수 없습니다."; + public static final String CURRENT_PASSWORD_MISMATCH = "기존 비밀번호가 일치하지 않습니다."; + public static final String NEW_PASSWORD_SAME_AS_CURRENT = "새 비밀번호는 기존 비밀번호와 달라야 합니다."; + public static final String PASSWORD_CONTAINS_BIRTH_DATE = "비밀번호에 생년월일을 포함할 수 없습니다."; + public static final String LOGIN_ID_REQUIRED = "로그인 ID는 필수입니다."; + public static final String PASSWORD_REQUIRED = "비밀번호는 필수입니다."; + public static final String BIRTH_DATE_REQUIRED = "생년월일은 필수입니다."; + public static final String NAME_REQUIRED = "이름은 필수입니다."; + public static final String EMAIL_REQUIRED = "이메일은 필수입니다."; + } + + /** + * Product 도메인 관련 에러 메시지 + */ + public static final class Product { + private Product() {} + + public static final String PRODUCT_NOT_FOUND = "상품을 찾을 수 없습니다"; + public static final String CREATE_PRODUCT_REQUEST_REQUIRED = "상품 생성 요청은 필수입니다"; + public static final String PRODUCT_ID_LIST_REQUIRED = "상품 ID 목록은 필수입니다"; + public static final String PRODUCT_ID_LIST_CONTAINS_INVALID = "존재하지 않는 상품이 포함되어 있습니다"; + public static final String SEARCH_CONDITION_REQUIRED = "검색 조건은 필수입니다"; + public static final String PRODUCT_NAME_REQUIRED = "상품 이름은 필수 입니다"; + public static final String BRAND_ID_INVALID = "브랜드FK는 null이거나 0이하가 될 수 없습니다"; + public static final String PRICE_INVALID = "상품 가격은 null이거나 음수가 될 수 없습니다"; + public static final String STOCK_INVALID = "상품 재고는 null이거나 음수가 될 수 없습니다"; + public static final String LIKE_COUNT_INVALID = "좋아요 수는 null이거나 음수가 될 수 없습니다"; + public static final String INSUFFICIENT_STOCK = "재고가 부족합니다"; + public static final String LIKE_COUNT_NEGATIVE = "좋아요 갯수는 음수가 될 수 없습니다"; + public static final String QUANTITY_MUST_BE_POSITIVE = "수량은 양수여야 합니다"; + } + + /** + * Brand 도메인 관련 에러 메시지 + */ + public static final class Brand { + private Brand() {} + + public static final String BRAND_NOT_FOUND = "브랜드를 찾을 수 없습니다"; + public static final String BRAND_NAME_REQUIRED = "브랜드 이름은 필수 입니다"; + } + + /** + * Order 도메인 관련 에러 메시지 + */ + public static final class Order { + private Order() {} + + public static final String ORDER_ITEMS_EMPTY = "주문할 상품이 없습니다"; + public static final String ORDER_QUANTITIES_EMPTY = "주문 수량 정보가 없습니다"; + public static final String ORDER_ID_INVALID = "주문FK는 null이거나 0이하가 될 수 없습니다"; + public static final String ORDER_STATUS_REQUIRED = "주문 상태는 필수입니다"; + public static final String ORDER_STATUS_CHANGE_DT_REQUIRED = "주문 상태 변경 일시는 필수입니다"; + public static final String PRODUCT_ID_INVALID = "상품FK는 null이거나 0이하가 될 수 없습니다"; + public static final String ORDER_AMOUNT_INVALID = "주문 금액은 null이거나 음수가 될 수 없습니다"; + public static final String USER_ID_INVALID = "유저FK는 null이거나 0이하가 될 수 없습니다"; + public static final String TOTAL_ORDER_AMOUNT_INVALID = "총 주문 금액은 null이거나 음수가 될 수 없습니다"; + public static final String ORDER_DT_REQUIRED = "주문 일시는 필수입니다"; + public static final String CANCEL_ONLY_WHEN_COMPLETED = "주문 완료 상태에서만 취소할 수 있습니다"; + public static final String QUANTITY_MUST_BE_POSITIVE = "수량은 양수여야 합니다"; + } + + /** + * Like 도메인 관련 에러 메시지 + */ + public static final class Like { + private Like() {} + + public static final String ALREADY_LIKED = "이미 좋아요를 누른 상품입니다"; + public static final String LIKE_NOT_FOUND = "좋아요 객체를 찾을 수 없습니다"; + public static final String PRODUCT_ID_INVALID = "상품FK는 null이거나 음수가 될 수 없습니다"; + public static final String USER_ID_INVALID = "유저FK는 null이거나 음수가 될 수 없습니다"; + } + + /** + * Example 도메인 관련 에러 메시지 + */ + public static final class Example { + private Example() {} + + public static final String EXAMPLE_NOT_FOUND = "예시를 찾을 수 없습니다."; + public static final String NAME_REQUIRED = "이름은 비어있을 수 없습니다."; + public static final String DESCRIPTION_REQUIRED = "설명은 비어있을 수 없습니다."; + } + + /** + * Auth 관련 에러 메시지 + */ + public static final class Auth { + private Auth() {} + + public static final String AUTH_HEADER_MISSING = "인증 헤더가 누락되었습니다."; + } +} 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 2476123fd..ebfcf0838 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 @@ -5,6 +5,7 @@ import static org.mockito.BDDMockito.given; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -45,7 +46,7 @@ void fail_when_password_contains_birthDate() { // act & assert assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) .isInstanceOf(CoreException.class) - .hasMessageContaining("비밀번호에 생년월일을 포함할 수 없습니다"); + .hasMessageContaining(ErrorMessage.User.PASSWORD_CONTAINS_BIRTH_DATE); } @Test @@ -61,7 +62,7 @@ void fail_when_email_already_exists() { assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) .isInstanceOf(CoreException.class) - .hasMessageContaining("이미 가입된 이메일입니다"); + .hasMessageContaining(ErrorMessage.User.EMAIL_ALREADY_EXISTS); } @Test @@ -77,7 +78,7 @@ void fail_when_loginId_already_exists() { assertThatThrownBy(() -> userService.createUser(loginId, rawPassword, birthDate, name, email)) .isInstanceOf(CoreException.class) - .hasMessageContaining("이미 사용 중인 아이디입니다"); + .hasMessageContaining(ErrorMessage.User.LOGIN_ID_ALREADY_EXISTS); } } @@ -121,7 +122,7 @@ void fail_when_currentPassword_not_match() { assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("기존 비밀번호가 일치하지 않습니다"); + .hasMessageContaining(ErrorMessage.User.CURRENT_PASSWORD_MISMATCH); } @Test @@ -140,7 +141,7 @@ void fail_when_newPassword_same_as_current() { assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("새 비밀번호는 기존 비밀번호와 달라야 합니다"); + .hasMessageContaining(ErrorMessage.User.NEW_PASSWORD_SAME_AS_CURRENT); } @Test @@ -161,7 +162,7 @@ void fail_when_newPassword_contains_birthDate() { // act & assert assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("비밀번호에 생년월일을 포함할 수 없습니다"); + .hasMessageContaining(ErrorMessage.User.PASSWORD_CONTAINS_BIRTH_DATE); } @Test @@ -177,7 +178,7 @@ void fail_when_user_not_found() { // act & assert assertThatThrownBy(() -> userService.changePassword(userId, currentPassword, newPassword)) .isInstanceOf(CoreException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); + .hasMessageContaining(ErrorMessage.User.USER_NOT_FOUND); } } } From a55e62ef907b74a591b7238cf15f2f60c5ce7635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Fri, 27 Feb 2026 09:23:16 +0900 Subject: [PATCH 52/55] =?UTF-8?q?refactor=20:=20primitive=ED=83=80?= =?UTF-8?q?=EC=9E=85=EC=9D=84=20Money=20VO=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 4 +-- .../loopers/application/order/OrderInfo.java | 2 +- .../application/product/ProductInfo.java | 2 +- .../java/com/loopers/domain/common/Money.java | 27 +++++++++++++++++++ .../java/com/loopers/domain/order/Order.java | 16 ++++------- .../com/loopers/domain/order/OrderItem.java | 16 +++-------- .../domain/order/OrderItemService.java | 12 ++++----- .../com/loopers/domain/product/Product.java | 16 ++++------- .../infrastructure/order/OrderEntity.java | 2 +- .../infrastructure/order/OrderItemEntity.java | 2 +- .../infrastructure/product/ProductEntity.java | 4 +-- .../loopers/support/error/ErrorMessage.java | 12 ++++++--- .../domain/order/OrderItemServiceTest.java | 5 ++-- .../loopers/domain/order/OrderItemTest.java | 12 +++++---- .../com/loopers/domain/order/OrderTest.java | 12 +++++---- .../loopers/domain/product/ProductTest.java | 6 +++-- 16 files changed, 83 insertions(+), 67 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Money.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 0226d1483..508160058 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 @@ -29,9 +29,9 @@ public OrderInfo order(OrderCommand command) { products.forEach(product -> productService.decreaseStock(product.getId(), command.productQuantities().get(product.getId()))); - int totalPrice = orderItemService.calculateTotalPrice(products, command.productQuantities()); + var totalPrice = orderItemService.calculateTotalPrice(products, command.productQuantities()); - var order = orderService.createOrder(command.userId(), totalPrice); + var order = orderService.createOrder(command.userId(), totalPrice.value()); orderItemService.createOrderItems(order.getId(), products, command.productQuantities()); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java index 2a914b574..0bc35b220 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -16,7 +16,7 @@ public static OrderInfo from(Order order) { order.getId(), order.getRefUserId(), order.getStatus(), - order.getTotalPrice(), + order.getTotalPrice().value(), order.getOrderDt() ); } 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 ae2575de9..7e8894b07 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 @@ -18,7 +18,7 @@ public static ProductInfo of(Product product, Brand brand) { return new ProductInfo( product.getId(), product.getName(), - product.getPrice(), + product.getPrice().value(), product.getStock(), product.getLikeCount(), brand.getId(), diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..7b87972d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,27 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; +import com.loopers.support.error.ErrorType; + +/** + * 금액 Value Object + */ +public record Money(Integer value) { + + public static final Money ZERO = new Money(0); + + public Money { + if (value == null || value < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Money.AMOUNT_INVALID); + } + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money multiply(int multiplier) { + return new Money(this.value * multiplier); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 1c9a4efba..ae9fce81c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; @@ -14,10 +15,10 @@ public class Order { private final Long refUserId; private OrderStatus status; - private Integer totalPrice; + private Money totalPrice; private ZonedDateTime orderDt; - private Order(Long id, Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { + private Order(Long id, Long refUserId, OrderStatus status, Money totalPrice, ZonedDateTime orderDt) { this.id = id; this.refUserId = refUserId; this.status = status; @@ -27,10 +28,9 @@ private Order(Long id, Long refUserId, OrderStatus status, Integer totalPrice, Z public static Order create(Long id, Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { validateRefUserId(refUserId); - validateTotalPrice(totalPrice); validateOrderDt(orderDt); - return new Order(id, refUserId, status, totalPrice, orderDt); + return new Order(id, refUserId, status, new Money(totalPrice), orderDt); } private static void validateRefUserId(Long refUserId) { @@ -39,12 +39,6 @@ private static void validateRefUserId(Long refUserId) { } } - private static void validateTotalPrice(Integer totalPrice) { - if (totalPrice == null || totalPrice < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.TOTAL_ORDER_AMOUNT_INVALID); - } - } - private static void validateOrderDt(ZonedDateTime orderDt) { if (orderDt == null) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_DT_REQUIRED); @@ -70,7 +64,7 @@ public OrderStatus getStatus() { return status; } - public Integer getTotalPrice() { + public Money getTotalPrice() { return totalPrice; } 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 28820bf5b..7f6fee7a5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; @@ -7,15 +8,14 @@ /** * OrderItem 도메인 */ -public record OrderItem(Long id, Long refOrderId, Long refProductId, Integer quantity, Integer price) { +public record OrderItem(Long id, Long refOrderId, Long refProductId, Integer quantity, Money price) { public static OrderItem create(Long id, Long refOrderId, Long refProductId, Integer quantity, Integer price) { validateRefOrderId(refOrderId); validateRefProductId(refProductId); validateQuantity(quantity); - validatePrice(price); - return new OrderItem(id, refOrderId, refProductId, quantity, price); + return new OrderItem(id, refOrderId, refProductId, quantity, new Money(price)); } private static void validateRefOrderId(Long refOrderId) { @@ -35,14 +35,4 @@ private static void validateQuantity(Integer quantity) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.QUANTITY_MUST_BE_POSITIVE); } } - - private static void validatePrice(Integer price) { - if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_AMOUNT_INVALID); - } - } - - public static int calculateSubtotal(int unitPrice, int quantity) { - return unitPrice * quantity; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java index 21ffaadbd..7f19fa7b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemService.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.common.Money; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorMessage; @@ -29,19 +30,16 @@ public void createOrderItems(Long orderId, List products, Map products, Map productQuantities) { + public Money calculateTotalPrice(List products, Map productQuantities) { return products.stream() - .mapToInt(product -> OrderItem.calculateSubtotal( - product.getPrice(), - productQuantities.get(product.getId()) - )) - .sum(); + .map(product -> product.getPrice().multiply(productQuantities.get(product.getId()))) + .reduce(Money.ZERO, Money::add); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 1b041db9a..4c9313b45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -1,5 +1,6 @@ package com.loopers.domain.product; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; @@ -13,11 +14,11 @@ public class Product { private final Long refBrandId; private String name; - private Integer price; + private Money price; private Integer stock; private Integer likeCount; - private Product(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { + private Product(Long id, String name, Long refBrandId, Money price, Integer stock, Integer likeCount) { this.id = id; this.name = name; this.refBrandId = refBrandId; @@ -29,11 +30,10 @@ private Product(Long id, String name, Long refBrandId, Integer price, Integer st public static Product create(Long id, String name, Long refBrandId, Integer price, Integer stock, Integer likeCount) { validateName(name); validateBrandId(refBrandId); - validatePrice(price); validateStock(stock); validateLike(likeCount); - return new Product(id, name, refBrandId, price, stock, likeCount); + return new Product(id, name, refBrandId, new Money(price), stock, likeCount); } private static void validateName(String name) { @@ -48,12 +48,6 @@ private static void validateBrandId(Long refBrand) { } } - private static void validatePrice(Integer price) { - if (price == null || price < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.PRICE_INVALID); - } - } - private static void validateStock(Integer stock) { if (stock == null || stock < 0) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Product.STOCK_INVALID); @@ -107,7 +101,7 @@ public Long getRefBrandId() { return refBrandId; } - public Integer getPrice() { + public Money getPrice() { return price; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java index 12bcdbfaa..c0e657693 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -48,7 +48,7 @@ public static OrderEntity create(Order order) { return new OrderEntity( order.getRefUserId(), order.getStatus(), - order.getTotalPrice(), + order.getTotalPrice().value(), order.getOrderDt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java index 48788fb3b..0df90c74e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -44,7 +44,7 @@ public static OrderItemEntity create(OrderItem orderItem) { orderItem.refOrderId(), orderItem.refProductId(), orderItem.quantity(), - orderItem.price() + orderItem.price().value() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java index 95b595c72..30bbd0c2b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java @@ -39,7 +39,7 @@ public class ProductEntity extends BaseEntity { public ProductEntity(Product product) { this.name = product.getName(); this.refBrandId = product.getRefBrandId(); - this.price = product.getPrice(); + this.price = product.getPrice().value(); this.likeCount = product.getLikeCount(); } @@ -61,7 +61,7 @@ public static Product toDomain(ProductEntity productEntity) { public void update(Product product) { this.name = product.getName(); this.refBrandId = product.getRefBrandId(); - this.price = product.getPrice(); + this.price = product.getPrice().value(); this.stock = product.getStock(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java index a449a225e..66b0f2b97 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorMessage.java @@ -30,6 +30,15 @@ private User() {} public static final String EMAIL_REQUIRED = "이메일은 필수입니다."; } + /** + * Money VO 관련 에러 메시지 + */ + public static final class Money { + private Money() {} + + public static final String AMOUNT_INVALID = "금액은 null이거나 음수가 될 수 없습니다"; + } + /** * Product 도메인 관련 에러 메시지 */ @@ -43,7 +52,6 @@ private Product() {} public static final String SEARCH_CONDITION_REQUIRED = "검색 조건은 필수입니다"; public static final String PRODUCT_NAME_REQUIRED = "상품 이름은 필수 입니다"; public static final String BRAND_ID_INVALID = "브랜드FK는 null이거나 0이하가 될 수 없습니다"; - public static final String PRICE_INVALID = "상품 가격은 null이거나 음수가 될 수 없습니다"; public static final String STOCK_INVALID = "상품 재고는 null이거나 음수가 될 수 없습니다"; public static final String LIKE_COUNT_INVALID = "좋아요 수는 null이거나 음수가 될 수 없습니다"; public static final String INSUFFICIENT_STOCK = "재고가 부족합니다"; @@ -73,9 +81,7 @@ private Order() {} public static final String ORDER_STATUS_REQUIRED = "주문 상태는 필수입니다"; public static final String ORDER_STATUS_CHANGE_DT_REQUIRED = "주문 상태 변경 일시는 필수입니다"; public static final String PRODUCT_ID_INVALID = "상품FK는 null이거나 0이하가 될 수 없습니다"; - public static final String ORDER_AMOUNT_INVALID = "주문 금액은 null이거나 음수가 될 수 없습니다"; public static final String USER_ID_INVALID = "유저FK는 null이거나 0이하가 될 수 없습니다"; - public static final String TOTAL_ORDER_AMOUNT_INVALID = "총 주문 금액은 null이거나 음수가 될 수 없습니다"; public static final String ORDER_DT_REQUIRED = "주문 일시는 필수입니다"; public static final String CANCEL_ONLY_WHEN_COMPLETED = "주문 완료 상태에서만 취소할 수 있습니다"; public static final String QUANTITY_MUST_BE_POSITIVE = "수량은 양수여야 합니다"; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java index 9403c0eb9..3dd364874 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.loopers.domain.common.Money; import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import java.util.List; @@ -69,9 +70,9 @@ void success_calculate_total_price() { 2L, 3 ); - int totalPrice = orderItemService.calculateTotalPrice(products, productQuantities); + Money totalPrice = orderItemService.calculateTotalPrice(products, productQuantities); - assertThat(totalPrice).isEqualTo(3500); + assertThat(totalPrice.value()).isEqualTo(3500); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java index c88894640..a9200d9cf 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -36,7 +38,7 @@ void success_create_order_item() { assertThat(orderItem.refOrderId()).isEqualTo(DEFAULT_ORDER_ID); assertThat(orderItem.refProductId()).isEqualTo(DEFAULT_PRODUCT_ID); assertThat(orderItem.quantity()).isEqualTo(DEFAULT_QUANTITY); - assertThat(orderItem.price()).isEqualTo(DEFAULT_PRICE); + assertThat(orderItem.price()).isEqualTo(new Money(DEFAULT_PRICE)); } @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") @@ -46,7 +48,7 @@ void success_create_order_item() { void fail_when_invalid_ref_order_id(Long refOrderId) { assertCoreException( () -> OrderItem.create(null, refOrderId, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, DEFAULT_PRICE), - "주문FK는 null이거나 0이하가 될 수 없습니다" + ErrorMessage.Order.ORDER_ID_INVALID ); } @@ -57,7 +59,7 @@ void fail_when_invalid_ref_order_id(Long refOrderId) { void fail_when_invalid_ref_product_id(Long refProductId) { assertCoreException( () -> OrderItem.create(null, DEFAULT_ORDER_ID, refProductId, DEFAULT_QUANTITY, DEFAULT_PRICE), - "상품FK는 null이거나 0이하가 될 수 없습니다" + ErrorMessage.Order.PRODUCT_ID_INVALID ); } @@ -68,7 +70,7 @@ void fail_when_invalid_ref_product_id(Long refProductId) { void fail_when_invalid_quantity(Integer quantity) { assertCoreException( () -> OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, quantity, DEFAULT_PRICE), - "수량은 양수여야 합니다" + ErrorMessage.Order.QUANTITY_MUST_BE_POSITIVE ); } @@ -79,7 +81,7 @@ void fail_when_invalid_quantity(Integer quantity) { void fail_when_invalid_price(Integer price) { assertCoreException( () -> OrderItem.create(null, DEFAULT_ORDER_ID, DEFAULT_PRODUCT_ID, DEFAULT_QUANTITY, price), - "주문 금액은 null이거나 음수가 될 수 없습니다" + ErrorMessage.Money.AMOUNT_INVALID ); } } 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 45e0da989..f0ee04e3b 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 @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import java.time.ZonedDateTime; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -38,7 +40,7 @@ void success_create_order() { Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); assertThat(order.getRefUserId()).isEqualTo(DEFAULT_USER_ID); - assertThat(order.getTotalPrice()).isEqualTo(DEFAULT_TOTAL_PRICE); + assertThat(order.getTotalPrice()).isEqualTo(new Money(DEFAULT_TOTAL_PRICE)); assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED); assertThat(order.getOrderDt()).isEqualTo(DEFAULT_ORDER_DT); } @@ -50,7 +52,7 @@ void success_create_order() { void fail_when_invalid_ref_user_id(Long refUserId) { assertCoreException( () -> createOrder(refUserId, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT), - "유저FK는 null이거나 0이하가 될 수 없습니다" + ErrorMessage.Order.USER_ID_INVALID ); } @@ -61,7 +63,7 @@ void fail_when_invalid_ref_user_id(Long refUserId) { void fail_when_invalid_total_price(Integer totalPrice) { assertCoreException( () -> createOrder(DEFAULT_USER_ID, totalPrice, DEFAULT_ORDER_DT), - "총 주문 금액은 null이거나 음수가 될 수 없습니다" + ErrorMessage.Money.AMOUNT_INVALID ); } @@ -70,7 +72,7 @@ void fail_when_invalid_total_price(Integer totalPrice) { void fail_when_order_dt_is_null() { assertCoreException( () -> createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, null), - "주문 일시는 필수입니다" + ErrorMessage.Order.ORDER_DT_REQUIRED ); } } @@ -95,7 +97,7 @@ void fail_when_already_cancelled() { Order order = createOrder(DEFAULT_USER_ID, DEFAULT_TOTAL_PRICE, DEFAULT_ORDER_DT); order.cancel(); - assertCoreException(() -> order.cancel(), "주문 완료 상태에서만 취소할 수 있습니다"); + assertCoreException(() -> order.cancel(), ErrorMessage.Order.CANCEL_ONLY_WHEN_COMPLETED); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 1db60a885..79578d833 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorMessage; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -48,7 +50,7 @@ void success_create_product() { assertThat(product.getId()).isNull(); assertThat(product.getName()).isEqualTo(DEFAULT_NAME); assertThat(product.getRefBrandId()).isEqualTo(DEFAULT_REF_BRAND_ID); - assertThat(product.getPrice()).isEqualTo(DEFAULT_PRICE); + assertThat(product.getPrice()).isEqualTo(new Money(DEFAULT_PRICE)); assertThat(product.getStock()).isEqualTo(DEFAULT_STOCK); assertThat(product.getLikeCount()).isEqualTo(DEFAULT_LIKE_COUNT); } @@ -82,7 +84,7 @@ void fail_when_invalid_brand_id(Long refBrandId) { void fail_when_invalid_price(Integer price) { assertCoreException( () -> createProduct(null, DEFAULT_NAME, DEFAULT_REF_BRAND_ID, price, DEFAULT_STOCK, DEFAULT_LIKE_COUNT), - "상품 가격은 null이거나 음수가 될 수 없습니다" + ErrorMessage.Money.AMOUNT_INVALID ); } From 72d3834c4c9438ecb36105340b0b28cd62ffc764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Sat, 28 Feb 2026 11:30:41 +0900 Subject: [PATCH 53/55] =?UTF-8?q?test=20:=20=EC=A2=8B=EC=95=84=EC=9A=94,?= =?UTF-8?q?=20=EC=A3=BC=EB=AC=B8,=20=EC=83=81=ED=92=88=20E2E=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/LikeRepositoryImpl.java | 3 +- .../interfaces/api/LikeV1ApiE2ETest.java | 207 ++++++++++++++ .../interfaces/api/OrderV1ApiE2ETest.java | 259 ++++++++++++++++++ .../interfaces/api/ProductV1ApiE2ETest.java | 201 ++++++++++++++ 4 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 8c7457ff7..aae1d8bc5 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 @@ -21,7 +21,8 @@ public Like save(Like like) { @Override public void delete(Like like) { - likeJpaRepository.delete(LikeEntity.toEntity(like)); + likeJpaRepository.findByRefProductIdAndRefUserId(like.getRefProductId(), like.getRefUserId()) + .ifPresent(likeJpaRepository::delete); } @Override diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..fe50f15c6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,207 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.user.UserModel; +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.utils.DatabaseCleanUp; +import java.time.LocalDate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private LikeJpaRepository likeJpaRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private BrandEntity savedBrand; + private UserModel savedUser; + private static final String LOGIN_ID = "testuser"; + private static final String LOGIN_PW = "Password1!"; + + @BeforeEach + void setUp() { + savedBrand = brandJpaRepository.save(createBrandEntity("나이키", "스포츠 브랜드")); + savedUser = userJpaRepository.save( + UserModel.create(LOGIN_ID, passwordEncoder.encode(LOGIN_PW), LocalDate.of(1990, 1, 1), "테스트유저", "test@test.com") + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/products/{productId}/like") + class ToggleLike { + + @Test + @DisplayName("좋아요를 누르면, 좋아요가 추가된다") + void success_add_like() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 0) + ); + + String url = "/api/v1/products/" + product.getId() + "/like"; + HttpHeaders headers = createAuthHeaders(); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(likeJpaRepository.existsByRefProductIdAndRefUserId(product.getId(), savedUser.getId())).isTrue() + ); + } + + @Test + @DisplayName("이미 좋아요한 상품에 다시 좋아요를 누르면, 좋아요가 취소된다") + void success_remove_like() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 1) + ); + + String url = "/api/v1/products/" + product.getId() + "/like"; + HttpHeaders headers = createAuthHeaders(); + var responseType = new ParameterizedTypeReference>() {}; + + // 첫 번째 좋아요 + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + assertThat(likeJpaRepository.existsByRefProductIdAndRefUserId(product.getId(), savedUser.getId())).isTrue(); + + // 두 번째 좋아요 (취소) + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(likeJpaRepository.existsByRefProductIdAndRefUserId(product.getId(), savedUser.getId())).isFalse() + ); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요를 누르면, 400 응답을 반환한다") + void fail_when_product_not_found() { + String url = "/api/v1/products/99999/like"; + HttpHeaders headers = createAuthHeaders(); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("인증 헤더가 없으면, 401 응답을 반환한다") + void fail_when_unauthorized() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 0) + ); + + String url = "/api/v1/products/" + product.getId() + "/like"; + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(null), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders createAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_ID, LOGIN_ID); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_PW, LOGIN_PW); + headers.set("Content-Type", "application/json"); + return headers; + } + + private BrandEntity createBrandEntity(String name, String description) { + try { + BrandEntity entity = BrandEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = BrandEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var descField = BrandEntity.class.getDeclaredField("description"); + descField.setAccessible(true); + descField.set(entity, description); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ProductEntity createProductEntity(String name, Long brandId, int price, int stock, int likeCount) { + try { + ProductEntity entity = ProductEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = ProductEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var brandIdField = ProductEntity.class.getDeclaredField("refBrandId"); + brandIdField.setAccessible(true); + brandIdField.set(entity, brandId); + + var priceField = ProductEntity.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(entity, price); + + var stockField = ProductEntity.class.getDeclaredField("stock"); + stockField.setAccessible(true); + stockField.set(entity, stock); + + var likeCountField = ProductEntity.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(entity, likeCount); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..17fdbb666 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,259 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.user.UserModel; +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private OrderJpaRepository orderJpaRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private BrandEntity savedBrand; + private UserModel savedUser; + private static final String LOGIN_ID = "testuser"; + private static final String LOGIN_PW = "Password1!"; + + @BeforeEach + void setUp() { + savedBrand = brandJpaRepository.save(createBrandEntity("나이키", "스포츠 브랜드")); + savedUser = userJpaRepository.save( + UserModel.create(LOGIN_ID, passwordEncoder.encode(LOGIN_PW), LocalDate.of(1990, 1, 1), "테스트유저", "test@test.com") + ); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("POST /api/v1/orders") + class CreateOrder { + + @Test + @DisplayName("주문을 생성하면, 주문 정보를 반환한다") + void success_create_order() { + ProductEntity product1 = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 100, 0) + ); + ProductEntity product2 = productJpaRepository.save( + createProductEntity("상품2", savedBrand.getId(), 20000, 50, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of( + new OrderV1Dto.OrderItemRequest(product1.getId(), 2), + new OrderV1Dto.OrderItemRequest(product2.getId(), 1) + ) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(savedUser.getId()), + () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(40000) // 10000*2 + 20000*1 + ); + } + + @Test + @DisplayName("주문 후 재고가 차감된다") + void success_decrease_stock_after_order() { + ProductEntity product = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 100, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 10)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + ProductEntity updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getStock()).isEqualTo(90); + } + + @Test + @DisplayName("재고가 부족하면, 400 응답을 반환한다") + void fail_when_stock_not_enough() { + ProductEntity product = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 5, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 10)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("존재하지 않는 상품을 주문하면, 400 응답을 반환한다") + void fail_when_product_not_found() { + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(99999L, 1)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("주문 상품 목록이 비어있으면, 400 응답을 반환한다") + void fail_when_order_items_empty() { + String url = "/api/v1/orders"; + HttpHeaders headers = createAuthHeaders(); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest(List.of()); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("인증 헤더가 없으면, 401 응답을 반환한다") + void fail_when_unauthorized() { + ProductEntity product = productJpaRepository.save( + createProductEntity("상품1", savedBrand.getId(), 10000, 100, 0) + ); + + String url = "/api/v1/orders"; + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(product.getId(), 1)) + ); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders createAuthHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_ID, LOGIN_ID); + headers.set(LoopersHeaders.X_LOOPERS_LOGIN_PW, LOGIN_PW); + headers.set("Content-Type", "application/json"); + return headers; + } + + private BrandEntity createBrandEntity(String name, String description) { + try { + BrandEntity entity = BrandEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = BrandEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var descField = BrandEntity.class.getDeclaredField("description"); + descField.setAccessible(true); + descField.set(entity, description); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ProductEntity createProductEntity(String name, Long brandId, int price, int stock, int likeCount) { + try { + ProductEntity entity = ProductEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = ProductEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var brandIdField = ProductEntity.class.getDeclaredField("refBrandId"); + brandIdField.setAccessible(true); + brandIdField.set(entity, brandId); + + var priceField = ProductEntity.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(entity, price); + + var stockField = ProductEntity.class.getDeclaredField("stock"); + stockField.setAccessible(true); + stockField.set(entity, stock); + + var likeCountField = ProductEntity.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(entity, likeCount); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..c029322dd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,201 @@ +package com.loopers.interfaces.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.loopers.infrastructure.brand.BrandEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + @Autowired + private TestRestTemplate testRestTemplate; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private BrandEntity savedBrand; + + @BeforeEach + void setUp() { + savedBrand = brandJpaRepository.save(createBrandEntity("나이키", "스포츠 브랜드")); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("GET /api/v1/products/{productId}") + class GetProduct { + + @Test + @DisplayName("존재하는 상품 ID로 조회하면, 상품 상세 정보를 반환한다") + void success_get_product() { + ProductEntity product = productJpaRepository.save( + createProductEntity("에어맥스", savedBrand.getId(), 150000, 100, 10) + ); + + String url = "/api/v1/products/" + product.getId(); + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().id()).isEqualTo(product.getId()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키") + ); + } + + @Test + @DisplayName("존재하지 않는 상품 ID로 조회하면, 400 응답을 반환한다") + void fail_when_product_not_found() { + String url = "/api/v1/products/99999"; + var responseType = new ParameterizedTypeReference>() {}; + + ResponseEntity> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("GET /api/v1/products") + class GetProducts { + + @Test + @DisplayName("상품 목록을 조회한다") + void success_get_products() { + productJpaRepository.save(createProductEntity("상품1", savedBrand.getId(), 10000, 50, 5)); + productJpaRepository.save(createProductEntity("상품2", savedBrand.getId(), 20000, 30, 10)); + + String url = "/api/v1/products"; + var responseType = new ParameterizedTypeReference>>() {}; + + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(2) + ); + } + + @Test + @DisplayName("브랜드 ID로 필터링하여 조회한다") + void success_get_products_by_brand() { + BrandEntity anotherBrand = brandJpaRepository.save(createBrandEntity("아디다스", "독일 브랜드")); + productJpaRepository.save(createProductEntity("나이키상품", savedBrand.getId(), 10000, 50, 5)); + productJpaRepository.save(createProductEntity("아디다스상품", anotherBrand.getId(), 20000, 30, 10)); + + String url = "/api/v1/products?brandId=" + savedBrand.getId(); + var responseType = new ParameterizedTypeReference>>() {}; + + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data()).hasSize(1), + () -> assertThat(response.getBody().data().get(0).name()).isEqualTo("나이키상품") + ); + } + + @Test + @DisplayName("가격 오름차순으로 정렬하여 조회한다") + void success_get_products_sorted_by_price() { + productJpaRepository.save(createProductEntity("비싼상품", savedBrand.getId(), 50000, 50, 5)); + productJpaRepository.save(createProductEntity("싼상품", savedBrand.getId(), 10000, 30, 10)); + + String url = "/api/v1/products?sortType=PRICE_ASC"; + var responseType = new ParameterizedTypeReference>>() {}; + + ResponseEntity>> response = + testRestTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null), responseType); + + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK), + () -> assertThat(response.getBody().data().get(0).name()).isEqualTo("싼상품"), + () -> assertThat(response.getBody().data().get(1).name()).isEqualTo("비싼상품") + ); + } + } + + private BrandEntity createBrandEntity(String name, String description) { + try { + BrandEntity entity = BrandEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = BrandEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var descField = BrandEntity.class.getDeclaredField("description"); + descField.setAccessible(true); + descField.set(entity, description); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private ProductEntity createProductEntity(String name, Long brandId, int price, int stock, int likeCount) { + try { + ProductEntity entity = ProductEntity.class.getDeclaredConstructor().newInstance(); + + var nameField = ProductEntity.class.getDeclaredField("name"); + nameField.setAccessible(true); + nameField.set(entity, name); + + var brandIdField = ProductEntity.class.getDeclaredField("refBrandId"); + brandIdField.setAccessible(true); + brandIdField.set(entity, brandId); + + var priceField = ProductEntity.class.getDeclaredField("price"); + priceField.setAccessible(true); + priceField.set(entity, price); + + var stockField = ProductEntity.class.getDeclaredField("stock"); + stockField.setAccessible(true); + stockField.set(entity, stock); + + var likeCountField = ProductEntity.class.getDeclaredField("likeCount"); + likeCountField.setAccessible(true); + likeCountField.set(entity, likeCount); + + return entity; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} From 66efe5b8baa4e4260169162eded10e2744b88b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Mon, 2 Mar 2026 22:46:06 +0900 Subject: [PATCH 54/55] =?UTF-8?q?refactor=20:=20Order,=20OrderItem,=20Orde?= =?UTF-8?q?rStatusHistory=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=93=A4=EC=97=90?= =?UTF-8?q?=20=EC=95=A0=EA=B7=B8=EB=A6=AC=EA=B1=B0=ED=8A=B8=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 24 ++-- .../java/com/loopers/domain/order/Order.java | 103 +++++++++++++++++- .../com/loopers/domain/order/OrderItem.java | 4 +- .../loopers/domain/order/OrderItemSpec.java | 14 +++ .../loopers/domain/order/OrderService.java | 13 +++ .../domain/order/OrderStatusHistory.java | 4 +- .../order/OrderItemJpaRepository.java | 2 + .../order/OrderRepositoryImpl.java | 79 +++++++++++++- .../OrderStatusHistoryJpaRepository.java | 2 + .../loopers/domain/order/OrderItemTest.java | 1 - .../domain/order/OrderStatusHistoryTest.java | 1 - 11 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.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 508160058..749a9f9aa 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,9 +1,8 @@ package com.loopers.application.order; -import com.loopers.domain.order.OrderItemService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItemSpec; import com.loopers.domain.order.OrderService; -import com.loopers.domain.order.OrderStatus; -import com.loopers.domain.order.OrderStatusHistoryService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import java.util.ArrayList; @@ -19,8 +18,6 @@ public class OrderFacade { private final ProductService productService; private final OrderService orderService; - private final OrderItemService orderItemService; - private final OrderStatusHistoryService orderStatusHistoryService; public OrderInfo order(OrderCommand command) { List productIds = new ArrayList<>(command.productQuantities().keySet()); @@ -29,14 +26,17 @@ public OrderInfo order(OrderCommand command) { products.forEach(product -> productService.decreaseStock(product.getId(), command.productQuantities().get(product.getId()))); - var totalPrice = orderItemService.calculateTotalPrice(products, command.productQuantities()); + List itemSpecs = products.stream() + .map(product -> new OrderItemSpec( + product.getId(), + product.getPrice(), + command.productQuantities().get(product.getId()) + )) + .toList(); - var order = orderService.createOrder(command.userId(), totalPrice.value()); + // 도메인 서비스에 주문 애그리거트 생성/저장 위임 + Order savedOrder = orderService.placeOrder(command.userId(), itemSpecs); - orderItemService.createOrderItems(order.getId(), products, command.productQuantities()); - - orderStatusHistoryService.recordHistory(order.getId(), OrderStatus.ORDERED); - - return OrderInfo.from(order); + return OrderInfo.from(savedOrder); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index ae9fce81c..a9656b7a9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -5,9 +5,11 @@ import com.loopers.support.error.ErrorMessage; import com.loopers.support.error.ErrorType; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; /** - * Order 도메인 + * Order 도메인 (애그리거트 루트) */ public class Order { @@ -18,6 +20,10 @@ public class Order { private Money totalPrice; private ZonedDateTime orderDt; + // 애그리거트 내부 엔티티 컬렉션 + private final List items = new ArrayList<>(); + private final List histories = new ArrayList<>(); + private Order(Long id, Long refUserId, OrderStatus status, Money totalPrice, ZonedDateTime orderDt) { this.id = id; this.refUserId = refUserId; @@ -33,6 +39,57 @@ public static Order create(Long id, Long refUserId, OrderStatus status, Integer return new Order(id, refUserId, status, new Money(totalPrice), orderDt); } + /** + * 저장소에서 복원할 때 사용하는 팩토리 메서드 + * items와 histories를 함께 받아서 복원한다. + */ + public static Order restore(Long id, Long refUserId, OrderStatus status, Integer totalPrice, + ZonedDateTime orderDt, List items, List histories) { + validateRefUserId(refUserId); + validateOrderDt(orderDt); + + Order order = new Order(id, refUserId, status, new Money(totalPrice), orderDt); + if (items != null) { + order.items.addAll(items); + } + if (histories != null) { + order.histories.addAll(histories); + } + return order; + } + + /** + * 주문 애그리거트 생성 팩토리 + * - 주문자, 주문 아이템 스펙, 주문 시각을 받아 애그리거트를 구성한다. + * - 현재 단계에서는 OrderItem / OrderStatusHistory 컬렉션을 조립하는 책임만 추가한다. + */ + public static Order place(Long userId, List itemSpecs, ZonedDateTime now) { + validateRefUserId(userId); + if (itemSpecs == null || itemSpecs.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ITEMS_EMPTY); + } + validateOrderDt(now); + + Money total = itemSpecs.stream() + .map(spec -> spec.price().multiply(spec.quantity())) + .reduce(Money.ZERO, Money::add); + + Order order = new Order( + null, + userId, + OrderStatus.ORDERED, + total, + now + ); + + itemSpecs.forEach(spec -> + order.addItem(spec.productId(), spec.price(), spec.quantity()) + ); + order.recordStatusChange(OrderStatus.ORDERED, now); + + return order; + } + private static void validateRefUserId(Long refUserId) { if (refUserId == null || refUserId <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.USER_ID_INVALID); @@ -45,6 +102,38 @@ private static void validateOrderDt(ZonedDateTime orderDt) { } } + /** + * 애그리거트 내부에 주문 아이템 추가 + */ + public void addItem(Long productId, Money unitPrice, int quantity) { + this.items.add(OrderItem.create( + null, + this.id, // 새 주문의 경우 null일 수 있지만, 인프라에서 매핑 시 채워진다 + productId, + quantity, + unitPrice.value() + )); + } + + /** + * 주문 상태 변경 + 이력 기록 + */ + public void recordStatusChange(OrderStatus newStatus, ZonedDateTime changedAt) { + if (newStatus == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_REQUIRED); + } + if (changedAt == null) { + throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_STATUS_CHANGE_DT_REQUIRED); + } + this.status = newStatus; + this.histories.add(OrderStatusHistory.create( + null, + this.id, + newStatus, + changedAt + )); + } + public void cancel() { if (this.status != OrderStatus.ORDERED) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.CANCEL_ONLY_WHEN_COMPLETED); @@ -71,4 +160,16 @@ public Money getTotalPrice() { public ZonedDateTime getOrderDt() { return orderDt; } + + /** + * 애그리거트 내부 컬렉션 조회용 (불변 뷰 반환) + */ + public List getItems() { + return List.copyOf(items); + } + + public List getHistories() { + return List.copyOf(histories); + } } + 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 7f6fee7a5..736c51277 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 @@ -19,7 +19,9 @@ public static OrderItem create(Long id, Long refOrderId, Long refProductId, Inte } private static void validateRefOrderId(Long refOrderId) { - if (refOrderId == null || refOrderId <= 0) { + // 새 Order 애그리거트 구성 시점에는 refOrderId가 아직 없을 수 있으므로 + // null 은 허용하고, 0 이하인 값만 검증 대상으로 본다. + if (refOrderId != null && refOrderId <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ID_INVALID); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.java new file mode 100644 index 000000000..97cf0a34f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemSpec.java @@ -0,0 +1,14 @@ +package com.loopers.domain.order; + +import com.loopers.domain.common.Money; + +/** + * 주문 시 사용되는 주문 상품 스펙 값 객체 + * - 애그리거트 외부(Application 계층 등)에서 Order 생성 시 사용 + */ +public record OrderItemSpec( + Long productId, + Money price, + int 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 b89b72540..0fac4eff9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -1,6 +1,7 @@ package com.loopers.domain.order; import java.time.ZonedDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -10,6 +11,9 @@ public class OrderService { private final OrderRepository orderRepository; + /** + * 기존 단순 생성용 메서드 (다른 곳에서 사용 중일 수 있어 유지) + */ public Order createOrder(Long refUserId, Integer totalPrice) { Order order = Order.create( null, @@ -20,4 +24,13 @@ public Order createOrder(Long refUserId, Integer totalPrice) { ); return orderRepository.save(order); } + + /** + * 애그리거트 기준 주문 생성 + * - Order.place(...)를 호출해 애그리거트를 만들고, OrderRepository를 통해 저장한다. + */ + public Order placeOrder(Long userId, List itemSpecs) { + Order order = Order.place(userId, itemSpecs, ZonedDateTime.now()); + return orderRepository.save(order); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java index fe1084f42..709101f75 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusHistory.java @@ -19,7 +19,9 @@ public static OrderStatusHistory create(Long id, Long refOrderId, OrderStatus st } private static void validateRefOrderId(Long refOrderId) { - if (refOrderId == null || refOrderId <= 0) { + // 새 Order 애그리거트 구성 시점에는 refOrderId가 아직 없을 수 있으므로 + // null 은 허용하고, 0 이하인 값만 검증 대상으로 본다. + if (refOrderId != null && refOrderId <= 0) { throw new CoreException(ErrorType.BAD_REQUEST, ErrorMessage.Order.ORDER_ID_INVALID); } } 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 index fb6f5a4b2..45f77468c 100644 --- 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 @@ -1,7 +1,9 @@ package com.loopers.infrastructure.order; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderItemJpaRepository extends JpaRepository { + List findByRefOrderId(Long refOrderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index cef1418b3..3591f6fed 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -1,7 +1,10 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderStatusHistory; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -10,10 +13,82 @@ public class OrderRepositoryImpl implements OrderRepository { private final OrderJpaRepository orderJpaRepository; + private final OrderItemJpaRepository orderItemJpaRepository; + private final OrderStatusHistoryJpaRepository orderStatusHistoryJpaRepository; @Override public Order save(Order order) { - OrderEntity entity = OrderEntity.create(order); - return OrderEntity.toDomain(orderJpaRepository.save(entity)); + // 1. Order 저장 + OrderEntity orderEntity = OrderEntity.create(order); + OrderEntity savedOrderEntity = orderJpaRepository.save(orderEntity); + + Long orderId = savedOrderEntity.getId(); + + // 2. OrderItem 저장 (애그리거트 내부 엔티티) + List items = order.getItems(); + if (items != null && !items.isEmpty()) { + List itemEntities = items.stream() + .map(item -> { + // refOrderId가 null이면 저장된 orderId 사용 + Long refOrderId = item.refOrderId() != null ? item.refOrderId() : orderId; + return OrderItemEntity.create( + new OrderItem( + item.id(), + refOrderId, + item.refProductId(), + item.quantity(), + item.price() + ) + ); + }) + .toList(); + orderItemJpaRepository.saveAll(itemEntities); + } + + // 3. OrderStatusHistory 저장 (애그리거트 내부 엔티티) + List histories = order.getHistories(); + if (histories != null && !histories.isEmpty()) { + List historyEntities = histories.stream() + .map(history -> { + // refOrderId가 null이면 저장된 orderId 사용 + Long refOrderId = history.refOrderId() != null ? history.refOrderId() : orderId; + return OrderStatusHistoryEntity.create( + new OrderStatusHistory( + history.id(), + refOrderId, + history.status(), + history.changedAt() + ) + ); + }) + .toList(); + orderStatusHistoryJpaRepository.saveAll(historyEntities); + } + + // 4. 저장된 Order 반환 (items, histories 포함) + return toDomainWithItemsAndHistories(savedOrderEntity, orderId); + } + + private Order toDomainWithItemsAndHistories(OrderEntity orderEntity, Long orderId) { + // OrderItem 조회 + List items = orderItemJpaRepository.findByRefOrderId(orderId).stream() + .map(OrderItemEntity::toDomain) + .toList(); + + // OrderStatusHistory 조회 + List histories = orderStatusHistoryJpaRepository.findByRefOrderId(orderId).stream() + .map(OrderStatusHistoryEntity::toDomain) + .toList(); + + // Order 복원 (items, histories 포함) + return Order.restore( + orderEntity.getId(), + orderEntity.getRefUserId(), + orderEntity.getStatus(), + orderEntity.getTotalPrice(), + orderEntity.getOrderDt(), + items, + histories + ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java index 2eb7ab9d7..e30b74e98 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryJpaRepository.java @@ -1,7 +1,9 @@ package com.loopers.infrastructure.order; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface OrderStatusHistoryJpaRepository extends JpaRepository { + List findByRefOrderId(Long refOrderId); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java index a9200d9cf..9eed7a70c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -43,7 +43,6 @@ void success_create_order_item() { @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") @ParameterizedTest - @NullSource @ValueSource(longs = {-1L, 0L}) void fail_when_invalid_ref_order_id(Long refOrderId) { assertCoreException( diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java index 6c136d591..faaa35544 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderStatusHistoryTest.java @@ -41,7 +41,6 @@ void success_create_order_status_history() { @DisplayName("주문FK가 유효하지 않으면, 생성시 예외를 던진다") @ParameterizedTest - @NullSource @ValueSource(longs = {-1L, 0L}) void fail_when_invalid_ref_order_id(Long refOrderId) { assertCoreException( From f04258a50a5820a81723712d86f29d2982d02b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=EA=B6=8C?= Date: Tue, 3 Mar 2026 00:24:43 +0900 Subject: [PATCH 55/55] =?UTF-8?q?refactor=20:=20Order,=20OrderItem,=20Orde?= =?UTF-8?q?rStatusHistory=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=EB=8F=84?= =?UTF-8?q?=20=EC=95=A0=EA=B7=B8=EB=A6=AC=EA=B1=B0=ED=8A=B8=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20+=20=EC=A0=80=EC=9E=A5,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=8B=9C=20cascade=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/order/OrderEntity.java | 53 +++++++++++-- .../infrastructure/order/OrderItemEntity.java | 14 ++-- .../order/OrderRepositoryImpl.java | 76 +------------------ .../order/OrderStatusHistoryEntity.java | 12 +-- .../OrderStatusHistoryRepositoryImpl.java | 2 +- 5 files changed, 60 insertions(+), 97 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java index c0e657693..ab53580d7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java @@ -2,13 +2,22 @@ import com.loopers.domain.BaseEntity; import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.order.OrderStatusHistory; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; import lombok.NoArgsConstructor; import org.hibernate.annotations.Comment; @@ -37,6 +46,14 @@ public class OrderEntity extends BaseEntity { @Column(name = "order_dt", nullable = false, updatable = false) private ZonedDateTime orderDt; + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "ref_order_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private List items = new ArrayList<>(); + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "ref_order_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private List histories = new ArrayList<>(); + private OrderEntity(Long refUserId, OrderStatus status, Integer totalPrice, ZonedDateTime orderDt) { this.refUserId = refUserId; this.status = status; @@ -45,21 +62,41 @@ private OrderEntity(Long refUserId, OrderStatus status, Integer totalPrice, Zone } public static OrderEntity create(Order order) { - return new OrderEntity( + OrderEntity entity = new OrderEntity( order.getRefUserId(), order.getStatus(), order.getTotalPrice().value(), order.getOrderDt() ); + + order.getItems().forEach(item -> + entity.items.add(OrderItemEntity.create(item)) + ); + + order.getHistories().forEach(history -> + entity.histories.add(OrderStatusHistoryEntity.create(history)) + ); + + return entity; } - public static Order toDomain(OrderEntity entity) { - return Order.create( - entity.getId(), - entity.refUserId, - entity.status, - entity.totalPrice, - entity.orderDt + public Order toDomain() { + List domainItems = items.stream() + .map(OrderItemEntity::toDomain) + .toList(); + + List domainHistories = histories.stream() + .map(OrderStatusHistoryEntity::toDomain) + .toList(); + + return Order.restore( + this.getId(), + this.refUserId, + this.status, + this.totalPrice, + this.orderDt, + domainItems, + domainHistories ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java index 0df90c74e..fcd9d95d9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemEntity.java @@ -17,7 +17,7 @@ public class OrderItemEntity extends BaseEntity { @Comment("주문 id (ref)") - @Column(name = "ref_order_id", nullable = false, updatable = false) + @Column(name = "ref_order_id", insertable = false, updatable = false) private Long refOrderId; @Comment("상품 id (ref)") @@ -48,13 +48,13 @@ public static OrderItemEntity create(OrderItem orderItem) { ); } - public static OrderItem toDomain(OrderItemEntity entity) { + public OrderItem toDomain() { return OrderItem.create( - entity.getId(), - entity.refOrderId, - entity.refProductId, - entity.quantity, - entity.price + this.getId(), + this.refOrderId, + this.refProductId, + this.quantity, + this.price ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 3591f6fed..a94076fde 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -1,10 +1,7 @@ package com.loopers.infrastructure.order; import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.order.OrderStatusHistory; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,82 +10,11 @@ public class OrderRepositoryImpl implements OrderRepository { private final OrderJpaRepository orderJpaRepository; - private final OrderItemJpaRepository orderItemJpaRepository; - private final OrderStatusHistoryJpaRepository orderStatusHistoryJpaRepository; @Override public Order save(Order order) { - // 1. Order 저장 OrderEntity orderEntity = OrderEntity.create(order); OrderEntity savedOrderEntity = orderJpaRepository.save(orderEntity); - - Long orderId = savedOrderEntity.getId(); - - // 2. OrderItem 저장 (애그리거트 내부 엔티티) - List items = order.getItems(); - if (items != null && !items.isEmpty()) { - List itemEntities = items.stream() - .map(item -> { - // refOrderId가 null이면 저장된 orderId 사용 - Long refOrderId = item.refOrderId() != null ? item.refOrderId() : orderId; - return OrderItemEntity.create( - new OrderItem( - item.id(), - refOrderId, - item.refProductId(), - item.quantity(), - item.price() - ) - ); - }) - .toList(); - orderItemJpaRepository.saveAll(itemEntities); - } - - // 3. OrderStatusHistory 저장 (애그리거트 내부 엔티티) - List histories = order.getHistories(); - if (histories != null && !histories.isEmpty()) { - List historyEntities = histories.stream() - .map(history -> { - // refOrderId가 null이면 저장된 orderId 사용 - Long refOrderId = history.refOrderId() != null ? history.refOrderId() : orderId; - return OrderStatusHistoryEntity.create( - new OrderStatusHistory( - history.id(), - refOrderId, - history.status(), - history.changedAt() - ) - ); - }) - .toList(); - orderStatusHistoryJpaRepository.saveAll(historyEntities); - } - - // 4. 저장된 Order 반환 (items, histories 포함) - return toDomainWithItemsAndHistories(savedOrderEntity, orderId); - } - - private Order toDomainWithItemsAndHistories(OrderEntity orderEntity, Long orderId) { - // OrderItem 조회 - List items = orderItemJpaRepository.findByRefOrderId(orderId).stream() - .map(OrderItemEntity::toDomain) - .toList(); - - // OrderStatusHistory 조회 - List histories = orderStatusHistoryJpaRepository.findByRefOrderId(orderId).stream() - .map(OrderStatusHistoryEntity::toDomain) - .toList(); - - // Order 복원 (items, histories 포함) - return Order.restore( - orderEntity.getId(), - orderEntity.getRefUserId(), - orderEntity.getStatus(), - orderEntity.getTotalPrice(), - orderEntity.getOrderDt(), - items, - histories - ); + return savedOrderEntity.toDomain(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java index 6d86a0f3f..c6dfce139 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryEntity.java @@ -21,7 +21,7 @@ public class OrderStatusHistoryEntity extends BaseEntity { @Comment("주문 id (ref)") - @Column(name = "ref_order_id", nullable = false, updatable = false) + @Column(name = "ref_order_id", insertable = false, updatable = false) private Long refOrderId; @Comment("주문 상태") @@ -47,12 +47,12 @@ public static OrderStatusHistoryEntity create(OrderStatusHistory history) { ); } - public static OrderStatusHistory toDomain(OrderStatusHistoryEntity entity) { + public OrderStatusHistory toDomain() { return OrderStatusHistory.create( - entity.getId(), - entity.refOrderId, - entity.status, - entity.changedAt + this.getId(), + this.refOrderId, + this.status, + this.changedAt ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java index c7fa8d598..da943f192 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderStatusHistoryRepositoryImpl.java @@ -14,6 +14,6 @@ public class OrderStatusHistoryRepositoryImpl implements OrderStatusHistoryRepos @Override public OrderStatusHistory save(OrderStatusHistory history) { OrderStatusHistoryEntity entity = OrderStatusHistoryEntity.create(history); - return OrderStatusHistoryEntity.toDomain(orderStatusHistoryJpaRepository.save(entity)); + return orderStatusHistoryJpaRepository.save(entity).toDomain(); } }